mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-04 11:05:56 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 481687fff3 | |||
| 46a9cf80b8 | |||
| f74c6605c4 | |||
| 215cc17c52 | |||
| c5bc849913 | |||
| 6178d1a45e | |||
| e12afd11cc |
@@ -784,6 +784,8 @@ Original SolidCraft drawings, Open for owner review; no runtime assignments.
|
|||||||
| `assets/locales/pending/sketch-offset-instances.json` | SolidCraft contributors | Original offset instance-count label and translations | MIT OR Apache-2.0 |
|
| `assets/locales/pending/sketch-offset-instances.json` | SolidCraft contributors | Original offset instance-count label and translations | MIT OR Apache-2.0 |
|
||||||
| assets/icons/ui/design/svg/flat/settings_profile.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original analytic head and shoulder geometry for settings profiles | MIT OR Apache-2.0 |
|
| assets/icons/ui/design/svg/flat/settings_profile.svg | SolidCraft contributors | single-colour application/navigation icon master (64 grid), original analytic head and shoulder geometry for settings profiles | MIT OR Apache-2.0 |
|
||||||
| tests/kernel-cases/import/4561-convert-box-mesh-with-a-zero-area-triangle/box-degenerate.stl | SolidCraft contributors | synthetic ASCII STL written by hand: a box with one zero-area triangle, for the kernel regression case of tangency-labs/solidcraft#4561 | MIT OR Apache-2.0 |
|
| tests/kernel-cases/import/4561-convert-box-mesh-with-a-zero-area-triangle/box-degenerate.stl | SolidCraft contributors | synthetic ASCII STL written by hand: a box with one zero-area triangle, for the kernel regression case of tangency-labs/solidcraft#4561 | MIT OR Apache-2.0 |
|
||||||
|
| assets/locales/pending/theme-blur-ui.json | SolidCraft contributors | Original blur controls, notices and translations | MIT OR Apache-2.0 |
|
||||||
|
| crates/ui-egui/tests/fixtures/theme-blur.json | SolidCraft contributors | Original theme for blur option scenarios and manager review | MIT OR Apache-2.0 |
|
||||||
|
|
||||||
## Code adapted from sibling projects
|
## Code adapted from sibling projects
|
||||||
|
|
||||||
|
|||||||
Generated
+1
@@ -4102,6 +4102,7 @@ dependencies = [
|
|||||||
"ureq",
|
"ureq",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
"winit",
|
"winit",
|
||||||
|
"winreg",
|
||||||
"x11rb",
|
"x11rb",
|
||||||
"zbus 5.19.0",
|
"zbus 5.19.0",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ mod web {
|
|||||||
|
|
||||||
use solidcraft_engine::Session;
|
use solidcraft_engine::Session;
|
||||||
use solidcraft_engine::io::vfs;
|
use solidcraft_engine::io::vfs;
|
||||||
|
use solidcraft_ui_egui::ui_kit::BackingTooltipExt as _;
|
||||||
use solidcraft_ui_egui::{Services, SolveApp, ui_kit};
|
use solidcraft_ui_egui::{Services, SolveApp, ui_kit};
|
||||||
use wasm_bindgen::JsCast as _;
|
use wasm_bindgen::JsCast as _;
|
||||||
|
|
||||||
@@ -500,7 +501,7 @@ mod web {
|
|||||||
files::pick_from_disk(&self.files, Intent::Open);
|
files::pick_from_disk(&self.files, Intent::Open);
|
||||||
}
|
}
|
||||||
if ui_kit::button(ui, "Insert from computer…", ui_kit::Button::Normal)
|
if ui_kit::button(ui, "Insert from computer…", ui_kit::Button::Normal)
|
||||||
.on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_drawings_hint"))
|
.backing_on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_drawings_hint"))
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
files::pick_from_disk(&self.files, Intent::Insert);
|
files::pick_from_disk(&self.files, Intent::Insert);
|
||||||
@@ -554,7 +555,7 @@ mod web {
|
|||||||
let design = solidcraft_engine::io::is_design_path(&e.name);
|
let design = solidcraft_engine::io::is_design_path(&e.name);
|
||||||
if !design
|
if !design
|
||||||
&& ui_kit::button(ui, "Insert", ui_kit::Button::Small)
|
&& ui_kit::button(ui, "Insert", ui_kit::Button::Small)
|
||||||
.on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_stored_drawings_hint"))
|
.backing_on_hover_text(solidcraft_ui_egui::i18n::tr("web.files.insert_stored_drawings_hint"))
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
act = Some(("insert", e.name.clone()));
|
act = Some(("insert", e.name.clone()));
|
||||||
@@ -564,7 +565,7 @@ mod web {
|
|||||||
self.rename_focus = true;
|
self.rename_focus = true;
|
||||||
}
|
}
|
||||||
if ui_kit::button(ui, "Save to computer…", ui_kit::Button::Small)
|
if ui_kit::button(ui, "Save to computer…", ui_kit::Button::Small)
|
||||||
.on_hover_text("A copy in a file you choose; the stored design stays here")
|
.backing_on_hover_text("A copy in a file you choose; the stored design stays here")
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
act = Some(("save_copy", e.name.clone()));
|
act = Some(("save_copy", e.name.clone()));
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ windows-sys = { version = "0.61", features = [
|
|||||||
"Win32_UI_HiDpi",
|
"Win32_UI_HiDpi",
|
||||||
"Win32_UI_WindowsAndMessaging",
|
"Win32_UI_WindowsAndMessaging",
|
||||||
] }
|
] }
|
||||||
|
winreg = "0.56"
|
||||||
|
|
||||||
[target.'cfg(target_os = "macos")'.dependencies]
|
[target.'cfg(target_os = "macos")'.dependencies]
|
||||||
muda = { version = "0.21.2", default-features = false }
|
muda = { version = "0.21.2", default-features = false }
|
||||||
@@ -92,7 +93,7 @@ hidapi = { version = "2.6.7", default-features = false, features = ["macos-share
|
|||||||
# Files opened from Finder and the Dock (src/open_documents.rs), and the fullscreen title-bar
|
# Files opened from Finder and the Dock (src/open_documents.rs), and the fullscreen title-bar
|
||||||
# inset (src/main.rs); all already come with eframe and rfd.
|
# inset (src/main.rs); all already come with eframe and rfd.
|
||||||
objc2 = "0.6"
|
objc2 = "0.6"
|
||||||
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow"] }
|
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSAccessibility", "NSApplication", "NSResponder", "NSWindow", "NSWorkspace"] }
|
||||||
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSNotification", "NSOperation", "NSProcessInfo", "NSString", "NSURL"] }
|
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "block2", "NSArray", "NSNotification", "NSOperation", "NSProcessInfo", "NSString", "NSURL"] }
|
||||||
block2 = "0.6"
|
block2 = "0.6"
|
||||||
# Native crash capture (src/native_crash_macos.rs): the signal handler, the faulting module
|
# Native crash capture (src/native_crash_macos.rs): the signal handler, the faulting module
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
//! Cached accessibility signal, observed only while a theme offers blur. No UI-thread IO.
|
||||||
|
use std::sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicBool, AtomicU8, Ordering},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Reading {
|
||||||
|
needed: AtomicBool,
|
||||||
|
started: AtomicBool,
|
||||||
|
value: AtomicU8,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cached(ctx: &egui::Context) -> Box<dyn Fn(bool) -> Option<bool>> {
|
||||||
|
let state = Arc::new(Reading::default());
|
||||||
|
let ctx = ctx.clone();
|
||||||
|
Box::new(move |needed| {
|
||||||
|
state.needed.store(needed, Ordering::Relaxed);
|
||||||
|
if needed && !state.started.swap(true, Ordering::Relaxed) {
|
||||||
|
let weak = Arc::downgrade(&state);
|
||||||
|
let ctx = ctx.clone();
|
||||||
|
if let Err(error) = std::thread::Builder::new().name("desktop-transparency".into()).spawn(move || {
|
||||||
|
while let Some(state) = weak.upgrade() {
|
||||||
|
if state.needed.load(Ordering::Relaxed) {
|
||||||
|
let code = match read() {
|
||||||
|
Some(true) => 1,
|
||||||
|
Some(false) => 2,
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
if state.value.swap(code, Ordering::Relaxed) != code {
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(state);
|
||||||
|
std::thread::sleep(std::time::Duration::from_secs(5));
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
log::warn!("desktop transparency: could not observe accessibility setting: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match state.value.load(Ordering::Relaxed) {
|
||||||
|
1 => Some(true),
|
||||||
|
2 => Some(false),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "windows")]
|
||||||
|
fn read() -> Option<bool> {
|
||||||
|
let key =
|
||||||
|
winreg::RegKey::predef(winreg::enums::HKEY_CURRENT_USER).open_subkey(r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize").ok()?;
|
||||||
|
match key.get_value::<u32, _>("EnableTransparency").ok()? {
|
||||||
|
0 => Some(true),
|
||||||
|
1 => Some(false),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn read() -> Option<bool> {
|
||||||
|
objc2::rc::autoreleasepool(|_| Some(objc2_app_kit::NSWorkspace::sharedWorkspace().accessibilityDisplayShouldReduceTransparency()))
|
||||||
|
}
|
||||||
@@ -24,6 +24,8 @@ mod come_forward;
|
|||||||
mod control_server;
|
mod control_server;
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
mod desktop_theme;
|
mod desktop_theme;
|
||||||
|
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||||
|
mod desktop_transparency;
|
||||||
mod device_lost;
|
mod device_lost;
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
mod native_crash;
|
mod native_crash;
|
||||||
@@ -539,6 +541,10 @@ fn services(cc: &eframe::CreationContext<'_>, folder: &DialogFolder) -> Services
|
|||||||
desktop_dark: Some(desktop_theme::start(&cc.egui_ctx)),
|
desktop_dark: Some(desktop_theme::start(&cc.egui_ctx)),
|
||||||
#[cfg(not(target_os = "linux"))]
|
#[cfg(not(target_os = "linux"))]
|
||||||
desktop_dark: None,
|
desktop_dark: None,
|
||||||
|
#[cfg(any(target_os = "windows", target_os = "macos"))]
|
||||||
|
reduce_transparency: Some(desktop_transparency::cached(&cc.egui_ctx)),
|
||||||
|
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||||
|
reduce_transparency: None,
|
||||||
http_get: Some(std::sync::Arc::new(http_get)),
|
http_get: Some(std::sync::Arc::new(http_get)),
|
||||||
http_download: Some(std::sync::Arc::new(http_download)),
|
http_download: Some(std::sync::Arc::new(http_download)),
|
||||||
// Already an installed app: only the browser build offers to install.
|
// Already an installed app: only the browser build offers to install.
|
||||||
|
|||||||
@@ -2265,6 +2265,14 @@
|
|||||||
"prefs.auto_operation_note": "ضم أو جسم جديد أو قطع، حسب ما تلامسه المادة.",
|
"prefs.auto_operation_note": "ضم أو جسم جديد أو قطع، حسب ما تلامسه المادة.",
|
||||||
"prefs.axes": "المحاور",
|
"prefs.axes": "المحاور",
|
||||||
"prefs.axis": "المحور",
|
"prefs.axis": "المحور",
|
||||||
|
"prefs.blur_cost": "تكلفة العرض النسبية. زيادة الأشرطة تعني عادةً مزيدًا من العمل.",
|
||||||
|
"prefs.blur_device": "غير متاح على جهاز الرسومات أو مسار العرض هذا",
|
||||||
|
"prefs.blur_guard": "خُفّضت جودة التمويه للحفاظ على استجابة الإطارات. لم يتغير نصف القطر.",
|
||||||
|
"prefs.blur_off": "إيقاف",
|
||||||
|
"prefs.blur_reduce_transparency": "معطّل بسبب إعداد تقليل الشفافية في النظام",
|
||||||
|
"prefs.blur_software": "غير متاح مع الرسومات البرمجية (gpu-software)",
|
||||||
|
"prefs.blur_source": "غير متاح في عرض مساحة العمل هذا",
|
||||||
|
"prefs.blur_web": "غير متاح في مسار عرض الويب هذا",
|
||||||
"prefs.bodies_folders": "مجلدات الأجسام",
|
"prefs.bodies_folders": "مجلدات الأجسام",
|
||||||
"prefs.bodies_folders_always": "دائمًا",
|
"prefs.bodies_folders_always": "دائمًا",
|
||||||
"prefs.bodies_folders_never": "أبدًا",
|
"prefs.bodies_folders_never": "أبدًا",
|
||||||
@@ -2650,6 +2658,10 @@
|
|||||||
"prefs.theme_follow_off": "إيقاف",
|
"prefs.theme_follow_off": "إيقاف",
|
||||||
"prefs.theme_follow_stop": "إيقاف الاتباع",
|
"prefs.theme_follow_stop": "إيقاف الاتباع",
|
||||||
"prefs.theme_import_hint": "انقر لاختيار ملف سمة، أو أفلت ملفًا هنا، أو اضغط Ctrl+V (Cmd+V على Mac) بعد نسخ سمة",
|
"prefs.theme_import_hint": "انقر لاختيار ملف سمة، أو أفلت ملفًا هنا، أو اضغط Ctrl+V (Cmd+V على Mac) بعد نسخ سمة",
|
||||||
|
"prefs.theme_notice_dismiss": "إخفاء",
|
||||||
|
"prefs.theme_option_blur": "تمويه خلف اللوحات",
|
||||||
|
"prefs.theme_option_blur_help": "تمويه خلف اللوحات شبه الشفافة، بالبكسل الفعلي. الصفر يوقف التمويه.",
|
||||||
|
"prefs.theme_option_invalid": "القيمة المحفوظة للخيار {option} غير صالحة؛ تُستخدم القيمة الافتراضية للسمة. تبقى القيمة المحفوظة كما هي.",
|
||||||
"prefs.theme_option_opacity": "العتامة",
|
"prefs.theme_option_opacity": "العتامة",
|
||||||
"prefs.theme_option_opacity_help": "اضبط عتامة اللوحات العائمة. يُطبّق حد أدنى للعتامة للحفاظ على وضوح النص فوق ألوان منطقة العرض.",
|
"prefs.theme_option_opacity_help": "اضبط عتامة اللوحات العائمة. يُطبّق حد أدنى للعتامة للحفاظ على وضوح النص فوق ألوان منطقة العرض.",
|
||||||
"prefs.theme_option_tab_colours": "ألوان علامات التبويب",
|
"prefs.theme_option_tab_colours": "ألوان علامات التبويب",
|
||||||
@@ -2658,6 +2670,7 @@
|
|||||||
"prefs.theme_option_tab_fade_ms_help": "تتغيّر ألوان اللوحات ومنطقة العرض تدريجيًا عند تبديل علامات التبويب. القيمة صفر تبدّلها فورًا.",
|
"prefs.theme_option_tab_fade_ms_help": "تتغيّر ألوان اللوحات ومنطقة العرض تدريجيًا عند تبديل علامات التبويب. القيمة صفر تبدّلها فورًا.",
|
||||||
"prefs.theme_options_badge": "الخيارات",
|
"prefs.theme_options_badge": "الخيارات",
|
||||||
"prefs.theme_options_more": "المزيد · {count}",
|
"prefs.theme_options_more": "المزيد · {count}",
|
||||||
|
"prefs.theme_options_newer": "تتطلب هذه السمة إصدارًا أحدث من SolidCraft. بعد الترقية، أعد استيراد السمة الأصلية لاستعادة تعريفات خياراتها.",
|
||||||
"prefs.theme_options_reset": "إعادة تعيين",
|
"prefs.theme_options_reset": "إعادة تعيين",
|
||||||
"prefs.theme_options_unavailable": "غير متاح على هذا الجهاز",
|
"prefs.theme_options_unavailable": "غير متاح على هذا الجهاز",
|
||||||
"prefs.theme_paste": "لصق السمة",
|
"prefs.theme_paste": "لصق السمة",
|
||||||
@@ -4941,6 +4954,10 @@
|
|||||||
"ui.fillet_setback_least": "أكثر من {least}",
|
"ui.fillet_setback_least": "أكثر من {least}",
|
||||||
"ui.fillet_setback_tip": "حيث تبدأ رقعة الزاوية الناعمة: المسافة على طول كل حافة من الزاوية. الافتراضي {factor} ضعف نصف القطر. يجب أن تكون أكبر من أقل مسافة إرجاع، حيث تلتقي الاستدارات بدونها: نصف القطر حيث تكون الأوجه متعامدة.",
|
"ui.fillet_setback_tip": "حيث تبدأ رقعة الزاوية الناعمة: المسافة على طول كل حافة من الزاوية. الافتراضي {factor} ضعف نصف القطر. يجب أن تكون أكبر من أقل مسافة إرجاع، حيث تلتقي الاستدارات بدونها: نصف القطر حيث تكون الأوجه متعامدة.",
|
||||||
"ui.fillet_smooth_radius": "تغيّر ناعم لنصف القطر",
|
"ui.fillet_smooth_radius": "تغيّر ناعم لنصف القطر",
|
||||||
|
"ui.fillet_turn": "المنعطفات",
|
||||||
|
"ui.fillet_turn_mitre": "وصلة مائلة",
|
||||||
|
"ui.fillet_turn_swept": "مُدار",
|
||||||
|
"ui.fillet_turn_tip": "حيث يلتف تقويسان حول حافة ركن حادة بينهما، مثل التقويسات على قاعدة بروز عند حوافه القائمة.\nوصلة مائلة: يمتد التقويسان حتى يلتقيا في وصلة مائلة.\nمُدار: يتوقف التقويسان عند حافة الركن ويلتفان حولها، والركن طارة مماسة لكليهما. فقط لتقويسين بنصف قطر واحد حول حافة ركن مستقيمة ومحدبة.",
|
||||||
"ui.fillet_type_choices_tip": "نصف قطر ثابت: نصف قطر واحد على طول الحافة.\nطول الوتر: يبقى عرض التقويس نفسه مهما كانت الزاوية بين الوجوه.\nنصف قطر متغيّر: نصف قطر عند كل طرف، ومزيد على طول الحافة إن أضفته.\nمتصل الانحناء: مقطع بلا انكسار في الانعكاسات حيث يلتقي بالوجوه المستوية (G2).\nمنحنى: يتبع نصف القطر على طول الحافة أنصاف أقطار تحكّم تحدّدها.",
|
"ui.fillet_type_choices_tip": "نصف قطر ثابت: نصف قطر واحد على طول الحافة.\nطول الوتر: يبقى عرض التقويس نفسه مهما كانت الزاوية بين الوجوه.\nنصف قطر متغيّر: نصف قطر عند كل طرف، ومزيد على طول الحافة إن أضفته.\nمتصل الانحناء: مقطع بلا انكسار في الانعكاسات حيث يلتقي بالوجوه المستوية (G2).\nمنحنى: يتبع نصف القطر على طول الحافة أنصاف أقطار تحكّم تحدّدها.",
|
||||||
"ui.fillet_type_curvature": "متصل الانحناء",
|
"ui.fillet_type_curvature": "متصل الانحناء",
|
||||||
"ui.fillet_type_tip": "المقطع عبر الحافة. متصل الانحناء: مقطع متصل الانحناء (G2) بين وجوه مستوية، فتمر الانعكاسات من الوجوه إلى التقويس دون انكسار.",
|
"ui.fillet_type_tip": "المقطع عبر الحافة. متصل الانحناء: مقطع متصل الانحناء (G2) بين وجوه مستوية، فتمر الانعكاسات من الوجوه إلى التقويس دون انكسار.",
|
||||||
|
|||||||
@@ -2248,6 +2248,14 @@
|
|||||||
"prefs.auto_operation_note": "Vereinigung, Neuer Körper oder Schnitt, je nachdem, was das Material berührt.",
|
"prefs.auto_operation_note": "Vereinigung, Neuer Körper oder Schnitt, je nachdem, was das Material berührt.",
|
||||||
"prefs.axes": "Achsen",
|
"prefs.axes": "Achsen",
|
||||||
"prefs.axis": "Achse",
|
"prefs.axis": "Achse",
|
||||||
|
"prefs.blur_cost": "Relativer Renderaufwand. Mehr Balken bedeuten meist mehr Arbeit.",
|
||||||
|
"prefs.blur_device": "Auf diesem Grafikgerät oder Renderpfad nicht verfügbar",
|
||||||
|
"prefs.blur_guard": "Die Unschärfequalität wurde für flüssigere Bilder verringert. Der Radius bleibt unverändert.",
|
||||||
|
"prefs.blur_off": "Aus",
|
||||||
|
"prefs.blur_reduce_transparency": "Durch die Systemeinstellung zur Transparenzreduzierung deaktiviert",
|
||||||
|
"prefs.blur_software": "Mit Softwaregrafik nicht verfügbar (gpu-software)",
|
||||||
|
"prefs.blur_source": "In dieser Arbeitsbereichsansicht nicht verfügbar",
|
||||||
|
"prefs.blur_web": "Auf diesem Web-Renderpfad nicht verfügbar",
|
||||||
"prefs.bodies_folders": "Körperordner",
|
"prefs.bodies_folders": "Körperordner",
|
||||||
"prefs.bodies_folders_always": "Immer",
|
"prefs.bodies_folders_always": "Immer",
|
||||||
"prefs.bodies_folders_never": "Nie",
|
"prefs.bodies_folders_never": "Nie",
|
||||||
@@ -2628,6 +2636,10 @@
|
|||||||
"prefs.theme_follow_off": "Aus",
|
"prefs.theme_follow_off": "Aus",
|
||||||
"prefs.theme_follow_stop": "Nicht mehr folgen",
|
"prefs.theme_follow_stop": "Nicht mehr folgen",
|
||||||
"prefs.theme_import_hint": "Klicken, um eine Designdatei zu wählen, eine hierher ziehen oder mit kopiertem Design Strg+V (Cmd+V auf dem Mac) drücken",
|
"prefs.theme_import_hint": "Klicken, um eine Designdatei zu wählen, eine hierher ziehen oder mit kopiertem Design Strg+V (Cmd+V auf dem Mac) drücken",
|
||||||
|
"prefs.theme_notice_dismiss": "Ausblenden",
|
||||||
|
"prefs.theme_option_blur": "Unschärfe hinter Panels",
|
||||||
|
"prefs.theme_option_blur_help": "Unschärfe hinter durchscheinenden Panels in physischen Pixeln. Null bedeutet aus.",
|
||||||
|
"prefs.theme_option_invalid": "Der gespeicherte Wert für {option} ist ungültig; der Designstandard wird verwendet. Der gespeicherte Wert bleibt erhalten.",
|
||||||
"prefs.theme_option_opacity": "Deckkraft",
|
"prefs.theme_option_opacity": "Deckkraft",
|
||||||
"prefs.theme_option_opacity_help": "Deckkraft schwebender Bedienfelder anpassen. Eine Mindestdeckkraft schützt die Lesbarkeit über den Ansichtsfarben.",
|
"prefs.theme_option_opacity_help": "Deckkraft schwebender Bedienfelder anpassen. Eine Mindestdeckkraft schützt die Lesbarkeit über den Ansichtsfarben.",
|
||||||
"prefs.theme_option_tab_colours": "Tabfarben",
|
"prefs.theme_option_tab_colours": "Tabfarben",
|
||||||
@@ -2636,6 +2648,7 @@
|
|||||||
"prefs.theme_option_tab_fade_ms_help": "Panel- und Ansichtsfarben beim Tabwechsel überblenden. Null wechselt sofort.",
|
"prefs.theme_option_tab_fade_ms_help": "Panel- und Ansichtsfarben beim Tabwechsel überblenden. Null wechselt sofort.",
|
||||||
"prefs.theme_options_badge": "Optionen",
|
"prefs.theme_options_badge": "Optionen",
|
||||||
"prefs.theme_options_more": "Mehr · {count}",
|
"prefs.theme_options_more": "Mehr · {count}",
|
||||||
|
"prefs.theme_options_newer": "Dieses Design benötigt eine neuere Version von SolidCraft. Nach dem Upgrade das Originaldesign erneut importieren, um seine Optionsdefinitionen wiederherzustellen.",
|
||||||
"prefs.theme_options_reset": "Zurücksetzen",
|
"prefs.theme_options_reset": "Zurücksetzen",
|
||||||
"prefs.theme_options_unavailable": "Auf diesem Gerät nicht verfügbar",
|
"prefs.theme_options_unavailable": "Auf diesem Gerät nicht verfügbar",
|
||||||
"prefs.theme_paste": "Design einfügen",
|
"prefs.theme_paste": "Design einfügen",
|
||||||
@@ -4914,6 +4927,10 @@
|
|||||||
"ui.fillet_setback_least": "Mehr als {least}",
|
"ui.fillet_setback_least": "Mehr als {least}",
|
||||||
"ui.fillet_setback_tip": "Wo das glatte Eckflächenstück beginnt: wie weit von der Ecke entlang jeder Kante. Standard ist {factor} × der Radius. Er muss größer sein als der kleinste Rücksprung, wo sich die Rundungen ohne ihn treffen: der Radius, wo die Flächen rechtwinklig zueinander stehen.",
|
"ui.fillet_setback_tip": "Wo das glatte Eckflächenstück beginnt: wie weit von der Ecke entlang jeder Kante. Standard ist {factor} × der Radius. Er muss größer sein als der kleinste Rücksprung, wo sich die Rundungen ohne ihn treffen: der Radius, wo die Flächen rechtwinklig zueinander stehen.",
|
||||||
"ui.fillet_smooth_radius": "Radius glatt ändern",
|
"ui.fillet_smooth_radius": "Radius glatt ändern",
|
||||||
|
"ui.fillet_turn": "Wendungen",
|
||||||
|
"ui.fillet_turn_mitre": "Gehrung",
|
||||||
|
"ui.fillet_turn_swept": "Geschwenkt",
|
||||||
|
"ui.fillet_turn_tip": "Wo zwei Rundungen um eine scharfe Eckkante zwischen ihnen herumlaufen, etwa die Rundungen am Fuß eines Zapfens an seinen senkrechten Kanten.\nGehrung: Die Rundungen laufen weiter, bis sie sich in einer Gehrung treffen.\nGeschwenkt: Die Rundungen enden an der Eckkante und schwenken um sie herum; die Ecke ist ein Torus, tangential zu beiden. Nur für Rundungen mit einem Radius um eine gerade, konvexe Eckkante.",
|
||||||
"ui.fillet_type_choices_tip": "Konstanter Radius: ein Radius entlang der ganzen Kante.\nSehnenlänge: die Breite der Rundung bleibt gleich, in welchem Winkel die Flächen auch aufeinandertreffen.\nVariabler Radius: ein Radius an jedem Ende und weitere entlang der Kante, wenn Sie sie hinzufügen.\nKrümmungsstetig: ein Querschnitt ohne Knick in den Spiegelungen, wo er auf ebene Flächen trifft (G2).\nSpline: der Radius entlang der Kante folgt Steuerradien, die Sie festlegen.",
|
"ui.fillet_type_choices_tip": "Konstanter Radius: ein Radius entlang der ganzen Kante.\nSehnenlänge: die Breite der Rundung bleibt gleich, in welchem Winkel die Flächen auch aufeinandertreffen.\nVariabler Radius: ein Radius an jedem Ende und weitere entlang der Kante, wenn Sie sie hinzufügen.\nKrümmungsstetig: ein Querschnitt ohne Knick in den Spiegelungen, wo er auf ebene Flächen trifft (G2).\nSpline: der Radius entlang der Kante folgt Steuerradien, die Sie festlegen.",
|
||||||
"ui.fillet_type_curvature": "Krümmungsstetig",
|
"ui.fillet_type_curvature": "Krümmungsstetig",
|
||||||
"ui.fillet_type_tip": "Der Querschnitt über die Kante. Krümmungsstetig: ein krümmungsstetiger Querschnitt (G2) zwischen ebenen Flächen, sodass Spiegelungen ohne Knick von den Flächen in die Rundung laufen.",
|
"ui.fillet_type_tip": "Der Querschnitt über die Kante. Krümmungsstetig: ein krümmungsstetiger Querschnitt (G2) zwischen ebenen Flächen, sodass Spiegelungen ohne Knick von den Flächen in die Rundung laufen.",
|
||||||
|
|||||||
@@ -2265,6 +2265,14 @@
|
|||||||
"prefs.auto_operation_note": "Join, New Body or Cut, from what the material touches.",
|
"prefs.auto_operation_note": "Join, New Body or Cut, from what the material touches.",
|
||||||
"prefs.axes": "Axes",
|
"prefs.axes": "Axes",
|
||||||
"prefs.axis": "Axis",
|
"prefs.axis": "Axis",
|
||||||
|
"prefs.blur_cost": "Relative rendering cost. More bars usually mean more work.",
|
||||||
|
"prefs.blur_device": "Unavailable on this graphics device or rendering path",
|
||||||
|
"prefs.blur_guard": "Blur quality was reduced to keep frames responsive. The radius is unchanged.",
|
||||||
|
"prefs.blur_off": "Off",
|
||||||
|
"prefs.blur_reduce_transparency": "Disabled by the system's Reduce transparency setting",
|
||||||
|
"prefs.blur_software": "Unavailable with software graphics (gpu-software)",
|
||||||
|
"prefs.blur_source": "Unavailable in this workspace view",
|
||||||
|
"prefs.blur_web": "Unavailable on this web rendering path",
|
||||||
"prefs.bodies_folders": "Bodies folders",
|
"prefs.bodies_folders": "Bodies folders",
|
||||||
"prefs.bodies_folders_always": "Always",
|
"prefs.bodies_folders_always": "Always",
|
||||||
"prefs.bodies_folders_never": "Never",
|
"prefs.bodies_folders_never": "Never",
|
||||||
@@ -2650,6 +2658,10 @@
|
|||||||
"prefs.theme_follow_off": "Off",
|
"prefs.theme_follow_off": "Off",
|
||||||
"prefs.theme_follow_stop": "Stop following",
|
"prefs.theme_follow_stop": "Stop following",
|
||||||
"prefs.theme_import_hint": "Click to choose a theme file, drop one here, or press Ctrl+V (Cmd+V on a Mac) with a theme copied",
|
"prefs.theme_import_hint": "Click to choose a theme file, drop one here, or press Ctrl+V (Cmd+V on a Mac) with a theme copied",
|
||||||
|
"prefs.theme_notice_dismiss": "Dismiss",
|
||||||
|
"prefs.theme_option_blur": "Blur behind panels",
|
||||||
|
"prefs.theme_option_blur_help": "Blur behind translucent panels, in physical pixels. Zero is off.",
|
||||||
|
"prefs.theme_option_invalid": "Saved value for {option} is invalid; using the theme default. The saved value is kept.",
|
||||||
"prefs.theme_option_opacity": "Opacity",
|
"prefs.theme_option_opacity": "Opacity",
|
||||||
"prefs.theme_option_opacity_help": "Adjust floating panel opacity. A readability floor protects text over viewport colors.",
|
"prefs.theme_option_opacity_help": "Adjust floating panel opacity. A readability floor protects text over viewport colors.",
|
||||||
"prefs.theme_option_tab_colours": "Tab colors",
|
"prefs.theme_option_tab_colours": "Tab colors",
|
||||||
@@ -2658,6 +2670,7 @@
|
|||||||
"prefs.theme_option_tab_fade_ms_help": "Fade panel and viewport colors when switching tabs. Zero switches instantly.",
|
"prefs.theme_option_tab_fade_ms_help": "Fade panel and viewport colors when switching tabs. Zero switches instantly.",
|
||||||
"prefs.theme_options_badge": "Options",
|
"prefs.theme_options_badge": "Options",
|
||||||
"prefs.theme_options_more": "More · {count}",
|
"prefs.theme_options_more": "More · {count}",
|
||||||
|
"prefs.theme_options_newer": "This theme requires a newer SolidCraft. Upgrade, then reimport the original theme to recover its option definitions.",
|
||||||
"prefs.theme_options_reset": "Reset",
|
"prefs.theme_options_reset": "Reset",
|
||||||
"prefs.theme_options_unavailable": "Unavailable on this device",
|
"prefs.theme_options_unavailable": "Unavailable on this device",
|
||||||
"prefs.theme_paste": "Paste theme",
|
"prefs.theme_paste": "Paste theme",
|
||||||
@@ -4941,6 +4954,10 @@
|
|||||||
"ui.fillet_setback_least": "More than {least}",
|
"ui.fillet_setback_least": "More than {least}",
|
||||||
"ui.fillet_setback_tip": "Where the smooth corner patch starts: how far along each edge from the corner. The default is {factor} × the radius. It must be more than the least setback, where the rounds meet without one: the radius where the faces are square to each other.",
|
"ui.fillet_setback_tip": "Where the smooth corner patch starts: how far along each edge from the corner. The default is {factor} × the radius. It must be more than the least setback, where the rounds meet without one: the radius where the faces are square to each other.",
|
||||||
"ui.fillet_smooth_radius": "Smooth radius change",
|
"ui.fillet_smooth_radius": "Smooth radius change",
|
||||||
|
"ui.fillet_turn": "Turns",
|
||||||
|
"ui.fillet_turn_mitre": "Mitre",
|
||||||
|
"ui.fillet_turn_swept": "Swept",
|
||||||
|
"ui.fillet_turn_tip": "Where two rounds turn round a sharp corner edge between them, such as the rounds along a boss's base at its upright edges.\nMitre: the rounds run on until they meet in a mitre.\nSwept: the rounds stop at the corner edge and turn round it, the corner a torus tangent to both. Only for rounds of one radius round a straight, convex corner edge.",
|
||||||
"ui.fillet_type_choices_tip": "Constant radius: one radius all along the edge.\nChord length: the width across the round stays the same, whatever angle the faces meet at.\nVariable radius: a radius at each end, and more along the edge if you add them.\nCurvature continuous: a section without a kink in reflections where it meets flat faces (G2).\nSpline: the radius along the edge follows control radii you set.",
|
"ui.fillet_type_choices_tip": "Constant radius: one radius all along the edge.\nChord length: the width across the round stays the same, whatever angle the faces meet at.\nVariable radius: a radius at each end, and more along the edge if you add them.\nCurvature continuous: a section without a kink in reflections where it meets flat faces (G2).\nSpline: the radius along the edge follows control radii you set.",
|
||||||
"ui.fillet_type_curvature": "Curvature continuous",
|
"ui.fillet_type_curvature": "Curvature continuous",
|
||||||
"ui.fillet_type_tip": "The section across the edge. Curvature continuous: a curvature-continuous (G2) section between planar faces, so reflections run from the faces into the round without a kink.",
|
"ui.fillet_type_tip": "The section across the edge. Curvature continuous: a curvature-continuous (G2) section between planar faces, so reflections run from the faces into the round without a kink.",
|
||||||
|
|||||||
@@ -2265,6 +2265,14 @@
|
|||||||
"prefs.auto_operation_note": "איחוד, גוף חדש או חיתוך, לפי מה שהחומר נוגע בו.",
|
"prefs.auto_operation_note": "איחוד, גוף חדש או חיתוך, לפי מה שהחומר נוגע בו.",
|
||||||
"prefs.axes": "צירים",
|
"prefs.axes": "צירים",
|
||||||
"prefs.axis": "ציר",
|
"prefs.axis": "ציר",
|
||||||
|
"prefs.blur_cost": "עלות עיבוד יחסית. יותר פסים מציינים בדרך כלל יותר עבודה.",
|
||||||
|
"prefs.blur_device": "לא זמין בהתקן הגרפי או בנתיב העיבוד הזה",
|
||||||
|
"prefs.blur_guard": "איכות הטשטוש הופחתה כדי לשמור על תגובתיות הפריימים. הרדיוס לא השתנה.",
|
||||||
|
"prefs.blur_off": "כבוי",
|
||||||
|
"prefs.blur_reduce_transparency": "מושבת עקב הגדרת הפחתת השקיפות של המערכת",
|
||||||
|
"prefs.blur_software": "לא זמין עם עיבוד גרפי בתוכנה (gpu-software)",
|
||||||
|
"prefs.blur_source": "לא זמין בתצוגת סביבת העבודה הזו",
|
||||||
|
"prefs.blur_web": "לא זמין בנתיב העיבוד הזה בדפדפן",
|
||||||
"prefs.bodies_folders": "תיקיות גופים",
|
"prefs.bodies_folders": "תיקיות גופים",
|
||||||
"prefs.bodies_folders_always": "תמיד",
|
"prefs.bodies_folders_always": "תמיד",
|
||||||
"prefs.bodies_folders_never": "אף פעם",
|
"prefs.bodies_folders_never": "אף פעם",
|
||||||
@@ -2650,6 +2658,10 @@
|
|||||||
"prefs.theme_follow_off": "כבוי",
|
"prefs.theme_follow_off": "כבוי",
|
||||||
"prefs.theme_follow_stop": "הפסקת המעקב",
|
"prefs.theme_follow_stop": "הפסקת המעקב",
|
||||||
"prefs.theme_import_hint": "לחצו כדי לבחור קובץ ערכת נושא, שחררו קובץ כאן, או הקישו Ctrl+V (Cmd+V ב-Mac) אחרי שהעתקתם ערכת נושא",
|
"prefs.theme_import_hint": "לחצו כדי לבחור קובץ ערכת נושא, שחררו קובץ כאן, או הקישו Ctrl+V (Cmd+V ב-Mac) אחרי שהעתקתם ערכת נושא",
|
||||||
|
"prefs.theme_notice_dismiss": "הסתרה",
|
||||||
|
"prefs.theme_option_blur": "טשטוש מאחורי לוחות",
|
||||||
|
"prefs.theme_option_blur_help": "טשטוש מאחורי לוחות שקופים למחצה, בפיקסלים פיזיים. אפס מכבה את הטשטוש.",
|
||||||
|
"prefs.theme_option_invalid": "הערך השמור עבור {option} אינו תקין; נעשה שימוש בברירת המחדל של ערכת הנושא. הערך השמור נשמר ללא שינוי.",
|
||||||
"prefs.theme_option_opacity": "אטימות",
|
"prefs.theme_option_opacity": "אטימות",
|
||||||
"prefs.theme_option_opacity_help": "כוונון אטימות הלוחות הצפים. רמת אטימות מינימלית שומרת על קריאות הטקסט על רקע צבעי חלון התצוגה.",
|
"prefs.theme_option_opacity_help": "כוונון אטימות הלוחות הצפים. רמת אטימות מינימלית שומרת על קריאות הטקסט על רקע צבעי חלון התצוגה.",
|
||||||
"prefs.theme_option_tab_colours": "צבעי כרטיסיות",
|
"prefs.theme_option_tab_colours": "צבעי כרטיסיות",
|
||||||
@@ -2658,6 +2670,7 @@
|
|||||||
"prefs.theme_option_tab_fade_ms_help": "שנה בהדרגה את צבעי הלוח ואזור התצוגה בעת מעבר בין כרטיסיות. ערך אפס מבצע את המעבר מיד.",
|
"prefs.theme_option_tab_fade_ms_help": "שנה בהדרגה את צבעי הלוח ואזור התצוגה בעת מעבר בין כרטיסיות. ערך אפס מבצע את המעבר מיד.",
|
||||||
"prefs.theme_options_badge": "אפשרויות",
|
"prefs.theme_options_badge": "אפשרויות",
|
||||||
"prefs.theme_options_more": "עוד · {count}",
|
"prefs.theme_options_more": "עוד · {count}",
|
||||||
|
"prefs.theme_options_newer": "ערכת נושא זו דורשת גרסה חדשה יותר של SolidCraft. לאחר השדרוג יש לייבא שוב את ערכת הנושא המקורית כדי לשחזר את הגדרות האפשרויות שלה.",
|
||||||
"prefs.theme_options_reset": "איפוס",
|
"prefs.theme_options_reset": "איפוס",
|
||||||
"prefs.theme_options_unavailable": "לא זמין במכשיר זה",
|
"prefs.theme_options_unavailable": "לא זמין במכשיר זה",
|
||||||
"prefs.theme_paste": "הדבקת ערכת נושא",
|
"prefs.theme_paste": "הדבקת ערכת נושא",
|
||||||
@@ -4941,6 +4954,10 @@
|
|||||||
"ui.fillet_setback_least": "יותר מ-{least}",
|
"ui.fillet_setback_least": "יותר מ-{least}",
|
||||||
"ui.fillet_setback_tip": "היכן מתחיל טלאי הפינה החלק: המרחק לאורך כל קצה מהפינה. ברירת המחדל היא פי {factor} מהרדיוס. עליה להיות גדולה מהנסיגה המזערית, שבה העיגולים נפגשים בלעדיה: הרדיוס כשהפאות ניצבות זו לזו.",
|
"ui.fillet_setback_tip": "היכן מתחיל טלאי הפינה החלק: המרחק לאורך כל קצה מהפינה. ברירת המחדל היא פי {factor} מהרדיוס. עליה להיות גדולה מהנסיגה המזערית, שבה העיגולים נפגשים בלעדיה: הרדיוס כשהפאות ניצבות זו לזו.",
|
||||||
"ui.fillet_smooth_radius": "שינוי רדיוס חלק",
|
"ui.fillet_smooth_radius": "שינוי רדיוס חלק",
|
||||||
|
"ui.fillet_turn": "פניות",
|
||||||
|
"ui.fillet_turn_mitre": "מיטר",
|
||||||
|
"ui.fillet_turn_swept": "מסובב",
|
||||||
|
"ui.fillet_turn_tip": "היכן ששני עיגולים מסתובבים סביב קצה פינה חד ביניהם, כמו העיגולים בבסיס בליטה ליד הקצוות הזקופים שלה.\nמיטר: העיגולים נמשכים עד שהם נפגשים במיטר.\nמסובב: העיגולים נעצרים בקצה הפינה ומסתובבים סביבו, והפינה היא טורוס המשיק לשניהם. רק לעיגולים ברדיוס אחד סביב קצה פינה ישר וקמור.",
|
||||||
"ui.fillet_type_choices_tip": "רדיוס קבוע: רדיוס אחד לכל אורך הקצה.\nאורך מיתר: רוחב העיגול נשאר זהה, בכל זווית שבה הפאות נפגשות.\nרדיוס משתנה: רדיוס בכל קצה, ועוד לאורך הקצה אם מוסיפים.\nרציף בעקמומיות: חתך בלי שבר בהשתקפויות היכן שהוא פוגש פאות מישוריות (G2).\nספליין: הרדיוס לאורך הקצה עוקב אחרי רדיוסי בקרה שאתם קובעים.",
|
"ui.fillet_type_choices_tip": "רדיוס קבוע: רדיוס אחד לכל אורך הקצה.\nאורך מיתר: רוחב העיגול נשאר זהה, בכל זווית שבה הפאות נפגשות.\nרדיוס משתנה: רדיוס בכל קצה, ועוד לאורך הקצה אם מוסיפים.\nרציף בעקמומיות: חתך בלי שבר בהשתקפויות היכן שהוא פוגש פאות מישוריות (G2).\nספליין: הרדיוס לאורך הקצה עוקב אחרי רדיוסי בקרה שאתם קובעים.",
|
||||||
"ui.fillet_type_curvature": "רציף בעקמומיות",
|
"ui.fillet_type_curvature": "רציף בעקמומיות",
|
||||||
"ui.fillet_type_tip": "החתך לרוחב הקצה. רציף בעקמומיות: חתך רציף בעקמומיות (G2) בין פאות מישוריות, כך שההשתקפויות עוברות מהפאות אל העיגול בלי שבר.",
|
"ui.fillet_type_tip": "החתך לרוחב הקצה. רציף בעקמומיות: חתך רציף בעקמומיות (G2) בין פאות מישוריות, כך שההשתקפויות עוברות מהפאות אל העיגול בלי שבר.",
|
||||||
|
|||||||
@@ -2265,6 +2265,14 @@
|
|||||||
"prefs.auto_operation_note": "材料が接触する対象に応じて結合、新規ボディ、切り取りを選びます。",
|
"prefs.auto_operation_note": "材料が接触する対象に応じて結合、新規ボディ、切り取りを選びます。",
|
||||||
"prefs.axes": "軸",
|
"prefs.axes": "軸",
|
||||||
"prefs.axis": "軸",
|
"prefs.axis": "軸",
|
||||||
|
"prefs.blur_cost": "相対的な描画負荷です。バーが多いほど、通常は処理量が増えます。",
|
||||||
|
"prefs.blur_device": "このグラフィックスデバイスまたは描画経路では利用できません",
|
||||||
|
"prefs.blur_guard": "画面の応答性を保つため、ぼかしの品質を下げました。半径は変更されていません。",
|
||||||
|
"prefs.blur_off": "オフ",
|
||||||
|
"prefs.blur_reduce_transparency": "システムの透明度を下げる設定により無効です",
|
||||||
|
"prefs.blur_software": "ソフトウェア描画では利用できません (gpu-software)",
|
||||||
|
"prefs.blur_source": "このワークスペース表示では利用できません",
|
||||||
|
"prefs.blur_web": "このウェブ描画経路では利用できません",
|
||||||
"prefs.bodies_folders": "ボディフォルダー",
|
"prefs.bodies_folders": "ボディフォルダー",
|
||||||
"prefs.bodies_folders_always": "常に表示",
|
"prefs.bodies_folders_always": "常に表示",
|
||||||
"prefs.bodies_folders_never": "表示しない",
|
"prefs.bodies_folders_never": "表示しない",
|
||||||
@@ -2650,6 +2658,10 @@
|
|||||||
"prefs.theme_follow_off": "オフ",
|
"prefs.theme_follow_off": "オフ",
|
||||||
"prefs.theme_follow_stop": "追従を停止",
|
"prefs.theme_follow_stop": "追従を停止",
|
||||||
"prefs.theme_import_hint": "クリックしてテーマファイルを選ぶか、ここにドロップするか、テーマをコピーした状態で Ctrl+V(Mac では Cmd+V)を押します",
|
"prefs.theme_import_hint": "クリックしてテーマファイルを選ぶか、ここにドロップするか、テーマをコピーした状態で Ctrl+V(Mac では Cmd+V)を押します",
|
||||||
|
"prefs.theme_notice_dismiss": "非表示",
|
||||||
|
"prefs.theme_option_blur": "パネル背面のぼかし",
|
||||||
|
"prefs.theme_option_blur_help": "半透明パネルの背面のぼかしを物理ピクセル単位で指定します。0でオフになります。",
|
||||||
|
"prefs.theme_option_invalid": "{option} の保存値が無効なため、テーマの既定値を使用します。保存値は保持されます。",
|
||||||
"prefs.theme_option_opacity": "不透明度",
|
"prefs.theme_option_opacity": "不透明度",
|
||||||
"prefs.theme_option_opacity_help": "フローティングパネルの不透明度を調整します。ビューポートの色に重なっても文字が読めるように下限が適用されます。",
|
"prefs.theme_option_opacity_help": "フローティングパネルの不透明度を調整します。ビューポートの色に重なっても文字が読めるように下限が適用されます。",
|
||||||
"prefs.theme_option_tab_colours": "タブの色",
|
"prefs.theme_option_tab_colours": "タブの色",
|
||||||
@@ -2658,6 +2670,7 @@
|
|||||||
"prefs.theme_option_tab_fade_ms_help": "タブの切り替え時にパネルとビューポートの色を徐々に変えます。0で即座に切り替えます。",
|
"prefs.theme_option_tab_fade_ms_help": "タブの切り替え時にパネルとビューポートの色を徐々に変えます。0で即座に切り替えます。",
|
||||||
"prefs.theme_options_badge": "オプション",
|
"prefs.theme_options_badge": "オプション",
|
||||||
"prefs.theme_options_more": "その他 · {count}",
|
"prefs.theme_options_more": "その他 · {count}",
|
||||||
|
"prefs.theme_options_newer": "このテーマには新しいバージョンの SolidCraft が必要です。更新後、元のテーマを再インポートしてオプション定義を復元してください。",
|
||||||
"prefs.theme_options_reset": "リセット",
|
"prefs.theme_options_reset": "リセット",
|
||||||
"prefs.theme_options_unavailable": "このデバイスでは利用できません",
|
"prefs.theme_options_unavailable": "このデバイスでは利用できません",
|
||||||
"prefs.theme_paste": "テーマを貼り付け",
|
"prefs.theme_paste": "テーマを貼り付け",
|
||||||
@@ -4941,6 +4954,10 @@
|
|||||||
"ui.fillet_setback_least": "{least} より大きく",
|
"ui.fillet_setback_least": "{least} より大きく",
|
||||||
"ui.fillet_setback_tip": "なめらかなコーナーパッチが始まる位置: コーナーから各エッジに沿った距離。既定値は半径の {factor} 倍。最小セットバック (セットバックなしでラウンド同士が交わる位置。面どうしが直角なら半径) より大きくする必要があります。",
|
"ui.fillet_setback_tip": "なめらかなコーナーパッチが始まる位置: コーナーから各エッジに沿った距離。既定値は半径の {factor} 倍。最小セットバック (セットバックなしでラウンド同士が交わる位置。面どうしが直角なら半径) より大きくする必要があります。",
|
||||||
"ui.fillet_smooth_radius": "半径をなめらかに変化",
|
"ui.fillet_smooth_radius": "半径をなめらかに変化",
|
||||||
|
"ui.fillet_turn": "曲がり角",
|
||||||
|
"ui.fillet_turn_mitre": "留め継ぎ",
|
||||||
|
"ui.fillet_turn_swept": "回転",
|
||||||
|
"ui.fillet_turn_tip": "2 つのフィレットが間にある鋭いコーナーエッジを回り込む所(ボスの根元のフィレットが立ち上がりエッジを回る所など)。\n留め継ぎ: フィレットは延びて留め継ぎで出会います。\n回転: フィレットはコーナーエッジで止まり、その周りを回ります。コーナーは両方に接するトーラスです。同じ半径のフィレットが、まっすぐな凸のコーナーエッジを回る場合のみ。",
|
||||||
"ui.fillet_type_choices_tip": "一定の半径: エッジ全体で一つの半径。\n弦長: 面がどんな角度で交わっても、フィレットの幅が同じ。\n可変半径: 両端の半径と、追加すればエッジ沿いの半径。\n曲率連続: 平面と接する所で映り込みが折れない断面 (G2)。\nスプライン: エッジ沿いの半径が、設定した制御半径に従います。",
|
"ui.fillet_type_choices_tip": "一定の半径: エッジ全体で一つの半径。\n弦長: 面がどんな角度で交わっても、フィレットの幅が同じ。\n可変半径: 両端の半径と、追加すればエッジ沿いの半径。\n曲率連続: 平面と接する所で映り込みが折れない断面 (G2)。\nスプライン: エッジ沿いの半径が、設定した制御半径に従います。",
|
||||||
"ui.fillet_type_curvature": "曲率連続",
|
"ui.fillet_type_curvature": "曲率連続",
|
||||||
"ui.fillet_type_tip": "エッジを横切る断面。曲率連続: 平面の間の曲率連続 (G2) 断面で、映り込みが面からフィレットへ折れずにつながります。",
|
"ui.fillet_type_tip": "エッジを横切る断面。曲率連続: 平面の間の曲率連続 (G2) 断面で、映り込みが面からフィレットへ折れずにつながります。",
|
||||||
|
|||||||
@@ -1657,6 +1657,14 @@
|
|||||||
"prefs.auto_operation_note": "재료가 닿는 대상에 따라 결합, 새 바디 또는 자르기를 선택합니다.",
|
"prefs.auto_operation_note": "재료가 닿는 대상에 따라 결합, 새 바디 또는 자르기를 선택합니다.",
|
||||||
"prefs.axes": "축",
|
"prefs.axes": "축",
|
||||||
"prefs.axis": "축",
|
"prefs.axis": "축",
|
||||||
|
"prefs.blur_cost": "상대적인 렌더링 비용입니다. 막대가 많을수록 일반적으로 더 많은 작업이 필요합니다.",
|
||||||
|
"prefs.blur_device": "이 그래픽 장치 또는 렌더링 경로에서는 사용할 수 없습니다",
|
||||||
|
"prefs.blur_guard": "화면의 원활한 응답을 위해 흐림 품질을 낮췄습니다. 반경은 변경되지 않습니다.",
|
||||||
|
"prefs.blur_off": "끄기",
|
||||||
|
"prefs.blur_reduce_transparency": "시스템의 투명도 줄이기 설정에 의해 비활성화됨",
|
||||||
|
"prefs.blur_software": "소프트웨어 그래픽에서는 사용할 수 없습니다 (gpu-software)",
|
||||||
|
"prefs.blur_source": "이 작업 공간 보기에서는 사용할 수 없습니다",
|
||||||
|
"prefs.blur_web": "이 웹 렌더링 경로에서는 사용할 수 없습니다",
|
||||||
"prefs.bodies_folders": "바디 폴더",
|
"prefs.bodies_folders": "바디 폴더",
|
||||||
"prefs.bodies_folders_always": "항상 표시",
|
"prefs.bodies_folders_always": "항상 표시",
|
||||||
"prefs.bodies_folders_never": "표시 안 함",
|
"prefs.bodies_folders_never": "표시 안 함",
|
||||||
@@ -1975,6 +1983,10 @@
|
|||||||
"prefs.theme_follow_note": "다른 도구가 파일을 다시 쓸 때마다 다시 불러옵니다. 테마를 선택하면 따르기를 중지합니다.",
|
"prefs.theme_follow_note": "다른 도구가 파일을 다시 쓸 때마다 다시 불러옵니다. 테마를 선택하면 따르기를 중지합니다.",
|
||||||
"prefs.theme_follow_off": "끄기",
|
"prefs.theme_follow_off": "끄기",
|
||||||
"prefs.theme_follow_stop": "따르기 중지",
|
"prefs.theme_follow_stop": "따르기 중지",
|
||||||
|
"prefs.theme_notice_dismiss": "닫기",
|
||||||
|
"prefs.theme_option_blur": "패널 뒤 흐림",
|
||||||
|
"prefs.theme_option_blur_help": "반투명 패널 뒤의 흐림을 물리적 픽셀 단위로 설정합니다. 0이면 꺼집니다.",
|
||||||
|
"prefs.theme_option_invalid": "{option}의 저장된 값이 유효하지 않아 테마 기본값을 사용합니다. 저장된 값은 유지됩니다.",
|
||||||
"prefs.theme_option_opacity": "불투명도",
|
"prefs.theme_option_opacity": "불투명도",
|
||||||
"prefs.theme_option_opacity_help": "플로팅 패널의 불투명도를 조정합니다. 뷰포트 색상 위에서도 텍스트를 읽을 수 있도록 최솟값이 적용됩니다.",
|
"prefs.theme_option_opacity_help": "플로팅 패널의 불투명도를 조정합니다. 뷰포트 색상 위에서도 텍스트를 읽을 수 있도록 최솟값이 적용됩니다.",
|
||||||
"prefs.theme_option_tab_colours": "탭 색상",
|
"prefs.theme_option_tab_colours": "탭 색상",
|
||||||
@@ -1983,6 +1995,7 @@
|
|||||||
"prefs.theme_option_tab_fade_ms_help": "탭을 전환할 때 패널 및 뷰포트 색상을 서서히 변경합니다. 0이면 즉시 전환합니다.",
|
"prefs.theme_option_tab_fade_ms_help": "탭을 전환할 때 패널 및 뷰포트 색상을 서서히 변경합니다. 0이면 즉시 전환합니다.",
|
||||||
"prefs.theme_options_badge": "옵션",
|
"prefs.theme_options_badge": "옵션",
|
||||||
"prefs.theme_options_more": "더 보기 · {count}",
|
"prefs.theme_options_more": "더 보기 · {count}",
|
||||||
|
"prefs.theme_options_newer": "이 테마에는 더 최신 버전의 SolidCraft가 필요합니다. 업그레이드 후 원본 테마를 다시 가져와 옵션 정의를 복구하세요.",
|
||||||
"prefs.theme_options_reset": "초기화",
|
"prefs.theme_options_reset": "초기화",
|
||||||
"prefs.theme_options_unavailable": "이 기기에서는 사용할 수 없음",
|
"prefs.theme_options_unavailable": "이 기기에서는 사용할 수 없음",
|
||||||
"prefs.timeline_half_hour": "−30분",
|
"prefs.timeline_half_hour": "−30분",
|
||||||
@@ -3712,6 +3725,10 @@
|
|||||||
"ui.fillet_setback_least": "{least}보다 크게",
|
"ui.fillet_setback_least": "{least}보다 크게",
|
||||||
"ui.fillet_setback_tip": "부드러운 코너 패치가 시작되는 위치: 코너에서 각 모서리를 따른 거리. 기본값은 반지름의 {factor}배. 최소 셋백(셋백 없이 라운드가 만나는 곳, 면이 서로 직각이면 반지름)보다 커야 합니다.",
|
"ui.fillet_setback_tip": "부드러운 코너 패치가 시작되는 위치: 코너에서 각 모서리를 따른 거리. 기본값은 반지름의 {factor}배. 최소 셋백(셋백 없이 라운드가 만나는 곳, 면이 서로 직각이면 반지름)보다 커야 합니다.",
|
||||||
"ui.fillet_smooth_radius": "반지름 부드럽게 변화",
|
"ui.fillet_smooth_radius": "반지름 부드럽게 변화",
|
||||||
|
"ui.fillet_turn": "꺾임",
|
||||||
|
"ui.fillet_turn_mitre": "마이터",
|
||||||
|
"ui.fillet_turn_swept": "회전",
|
||||||
|
"ui.fillet_turn_tip": "두 라운드가 사이의 날카로운 코너 모서리를 돌아가는 곳(보스 밑동의 라운드가 세로 모서리를 도는 곳 등).\n마이터: 라운드가 계속 이어져 마이터로 만납니다.\n회전: 라운드가 코너 모서리에서 멈추고 그 둘레를 돕니다. 코너는 둘 모두에 접하는 토러스입니다. 반지름이 같은 라운드가 곧고 볼록한 코너 모서리를 돌 때만.",
|
||||||
"ui.fillet_type_choices_tip": "일정한 반지름: 모서리 전체에 하나의 반지름.\n현 길이: 면이 어떤 각도로 만나든 라운드의 폭이 같습니다.\n가변 반지름: 양 끝의 반지름과, 추가하면 모서리를 따라 더.\n곡률 연속: 평면과 만나는 곳에서 반사가 꺾이지 않는 단면(G2).\n스플라인: 모서리를 따라 반지름이 설정한 제어 반지름을 따릅니다.",
|
"ui.fillet_type_choices_tip": "일정한 반지름: 모서리 전체에 하나의 반지름.\n현 길이: 면이 어떤 각도로 만나든 라운드의 폭이 같습니다.\n가변 반지름: 양 끝의 반지름과, 추가하면 모서리를 따라 더.\n곡률 연속: 평면과 만나는 곳에서 반사가 꺾이지 않는 단면(G2).\n스플라인: 모서리를 따라 반지름이 설정한 제어 반지름을 따릅니다.",
|
||||||
"ui.fillet_type_curvature": "곡률 연속",
|
"ui.fillet_type_curvature": "곡률 연속",
|
||||||
"ui.fillet_type_tip": "모서리를 가로지르는 단면. 곡률 연속: 평면 사이의 곡률 연속(G2) 단면으로, 반사가 면에서 라운드로 꺾임 없이 이어집니다.",
|
"ui.fillet_type_tip": "모서리를 가로지르는 단면. 곡률 연속: 평면 사이의 곡률 연속(G2) 단면으로, 반사가 면에서 라운드로 꺾임 없이 이어집니다.",
|
||||||
|
|||||||
@@ -2265,6 +2265,14 @@
|
|||||||
"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.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.axes": "Các trục tọa độ",
|
||||||
"prefs.axis": "Trục tọa độ",
|
"prefs.axis": "Trục tọa độ",
|
||||||
|
"prefs.blur_cost": "Chi phí kết xuất tương đối. Nhiều thanh hơn thường có nghĩa là cần xử lý nhiều hơn.",
|
||||||
|
"prefs.blur_device": "Không khả dụng trên thiết bị đồ họa hoặc đường kết xuất này",
|
||||||
|
"prefs.blur_guard": "Chất lượng làm mờ đã giảm để duy trì độ phản hồi của khung hình. Bán kính không thay đổi.",
|
||||||
|
"prefs.blur_off": "Tắt",
|
||||||
|
"prefs.blur_reduce_transparency": "Bị tắt bởi cài đặt Giảm độ trong suốt của hệ thống",
|
||||||
|
"prefs.blur_software": "Không khả dụng với đồ họa phần mềm (gpu-software)",
|
||||||
|
"prefs.blur_source": "Không khả dụng trong chế độ xem không gian làm việc này",
|
||||||
|
"prefs.blur_web": "Không khả dụng trên đường kết xuất web này",
|
||||||
"prefs.bodies_folders": "Thư mục thân khối",
|
"prefs.bodies_folders": "Thư mục thân khối",
|
||||||
"prefs.bodies_folders_always": "Luôn luôn",
|
"prefs.bodies_folders_always": "Luôn luôn",
|
||||||
"prefs.bodies_folders_never": "Không bao giờ",
|
"prefs.bodies_folders_never": "Không bao giờ",
|
||||||
@@ -2650,6 +2658,10 @@
|
|||||||
"prefs.theme_follow_off": "Tắt",
|
"prefs.theme_follow_off": "Tắt",
|
||||||
"prefs.theme_follow_stop": "Dừng theo dõi",
|
"prefs.theme_follow_stop": "Dừng theo dõi",
|
||||||
"prefs.theme_import_hint": "Nhấp để chọn tệp giao diện, thả tệp vào đây, hoặc nhấn Ctrl+V (Cmd+V trên Mac) khi đã sao chép một giao diện",
|
"prefs.theme_import_hint": "Nhấp để chọn tệp giao diện, thả tệp vào đây, hoặc nhấn Ctrl+V (Cmd+V trên Mac) khi đã sao chép một giao diện",
|
||||||
|
"prefs.theme_notice_dismiss": "Ẩn",
|
||||||
|
"prefs.theme_option_blur": "Làm mờ phía sau bảng",
|
||||||
|
"prefs.theme_option_blur_help": "Làm mờ phía sau các bảng bán trong suốt, tính bằng pixel vật lý. Giá trị 0 là tắt.",
|
||||||
|
"prefs.theme_option_invalid": "Giá trị đã lưu cho {option} không hợp lệ; đang dùng giá trị mặc định của giao diện. Giá trị đã lưu vẫn được giữ lại.",
|
||||||
"prefs.theme_option_opacity": "Độ đục",
|
"prefs.theme_option_opacity": "Độ đục",
|
||||||
"prefs.theme_option_opacity_help": "Điều chỉnh độ đục của các bảng nổi. Giới hạn độ đục tối thiểu giúp văn bản luôn dễ đọc trên nền màu của khung nhìn.",
|
"prefs.theme_option_opacity_help": "Điều chỉnh độ đục của các bảng nổi. Giới hạn độ đục tối thiểu giúp văn bản luôn dễ đọc trên nền màu của khung nhìn.",
|
||||||
"prefs.theme_option_tab_colours": "Màu thẻ",
|
"prefs.theme_option_tab_colours": "Màu thẻ",
|
||||||
@@ -2658,6 +2670,7 @@
|
|||||||
"prefs.theme_option_tab_fade_ms_help": "Chuyển dần màu của bảng điều khiển và khung nhìn khi đổi thẻ. Đặt thành 0 để chuyển ngay lập tức.",
|
"prefs.theme_option_tab_fade_ms_help": "Chuyển dần màu của bảng điều khiển và khung nhìn khi đổi thẻ. Đặt thành 0 để chuyển ngay lập tức.",
|
||||||
"prefs.theme_options_badge": "Tùy chọn",
|
"prefs.theme_options_badge": "Tùy chọn",
|
||||||
"prefs.theme_options_more": "Thêm · {count}",
|
"prefs.theme_options_more": "Thêm · {count}",
|
||||||
|
"prefs.theme_options_newer": "Giao diện này yêu cầu phiên bản SolidCraft mới hơn. Sau khi nâng cấp, hãy nhập lại giao diện gốc để khôi phục các định nghĩa tùy chọn.",
|
||||||
"prefs.theme_options_reset": "Đặt lại",
|
"prefs.theme_options_reset": "Đặt lại",
|
||||||
"prefs.theme_options_unavailable": "Không khả dụng trên thiết bị này",
|
"prefs.theme_options_unavailable": "Không khả dụng trên thiết bị này",
|
||||||
"prefs.theme_paste": "Dán giao diện",
|
"prefs.theme_paste": "Dán giao diện",
|
||||||
@@ -4941,6 +4954,10 @@
|
|||||||
"ui.fillet_setback_least": "Lớn hơn {least}",
|
"ui.fillet_setback_least": "Lớn hơn {least}",
|
||||||
"ui.fillet_setback_tip": "Nơi mảng góc trơn bắt đầu: khoảng cách dọc theo mỗi cạnh tính từ góc. Mặc định là {factor} × bán kính. Giá trị phải lớn hơn khoảng lùi nhỏ nhất, tại đó các đường bo gặp nhau mà không cần lùi: bằng bán kính khi các mặt vuông góc với nhau.",
|
"ui.fillet_setback_tip": "Nơi mảng góc trơn bắt đầu: khoảng cách dọc theo mỗi cạnh tính từ góc. Mặc định là {factor} × bán kính. Giá trị phải lớn hơn khoảng lùi nhỏ nhất, tại đó các đường bo gặp nhau mà không cần lùi: bằng bán kính khi các mặt vuông góc với nhau.",
|
||||||
"ui.fillet_smooth_radius": "Thay đổi bán kính trơn",
|
"ui.fillet_smooth_radius": "Thay đổi bán kính trơn",
|
||||||
|
"ui.fillet_turn": "Chỗ rẽ",
|
||||||
|
"ui.fillet_turn_mitre": "Vát góc",
|
||||||
|
"ui.fillet_turn_swept": "Quét vòng",
|
||||||
|
"ui.fillet_turn_tip": "Nơi hai góc lượn rẽ quanh một cạnh góc sắc nằm giữa chúng, ví dụ các góc lượn ở chân một khối lồi tại các cạnh đứng của nó.\nVát góc: các góc lượn kéo dài cho đến khi gặp nhau theo đường vát.\nQuét vòng: các góc lượn dừng ở cạnh góc và quay quanh nó, góc là một mặt xuyến tiếp xúc với cả hai. Chỉ cho các góc lượn cùng bán kính quanh một cạnh góc thẳng và lồi.",
|
||||||
"ui.fillet_type_choices_tip": "Bán kính không đổi: một bán kính dọc suốt cạnh.\nĐộ dài dây cung: độ rộng ngang đường bo giữ nguyên, bất kể góc gặp nhau của các mặt.\nBán kính thay đổi: một bán kính ở mỗi đầu, và thêm dọc cạnh nếu bạn thêm vào.\nLiên tục độ cong: mặt cắt không làm gãy phản chiếu tại nơi gặp các mặt phẳng (G2).\nSpline: bán kính dọc cạnh theo các bán kính điều khiển bạn đặt.",
|
"ui.fillet_type_choices_tip": "Bán kính không đổi: một bán kính dọc suốt cạnh.\nĐộ dài dây cung: độ rộng ngang đường bo giữ nguyên, bất kể góc gặp nhau của các mặt.\nBán kính thay đổi: một bán kính ở mỗi đầu, và thêm dọc cạnh nếu bạn thêm vào.\nLiên tục độ cong: mặt cắt không làm gãy phản chiếu tại nơi gặp các mặt phẳng (G2).\nSpline: bán kính dọc cạnh theo các bán kính điều khiển bạn đặt.",
|
||||||
"ui.fillet_type_curvature": "Liên tục độ cong",
|
"ui.fillet_type_curvature": "Liên tục độ cong",
|
||||||
"ui.fillet_type_tip": "Mặt cắt ngang qua cạnh. Liên tục độ cong: mặt cắt liên tục độ cong (G2) giữa các mặt phẳng, nên phản chiếu đi từ các mặt vào đường bo mà không bị gãy.",
|
"ui.fillet_type_tip": "Mặt cắt ngang qua cạnh. Liên tục độ cong: mặt cắt liên tục độ cong (G2) giữa các mặt phẳng, nên phản chiếu đi từ các mặt vào đường bo mà không bị gãy.",
|
||||||
|
|||||||
@@ -2265,6 +2265,14 @@
|
|||||||
"prefs.auto_operation_note": "根据材料接触的对象选择合并、新建实体或切割。",
|
"prefs.auto_operation_note": "根据材料接触的对象选择合并、新建实体或切割。",
|
||||||
"prefs.axes": "坐标轴",
|
"prefs.axes": "坐标轴",
|
||||||
"prefs.axis": "轴",
|
"prefs.axis": "轴",
|
||||||
|
"prefs.blur_cost": "相对渲染开销。条形越多,通常意味着处理工作越多。",
|
||||||
|
"prefs.blur_device": "此图形设备或渲染路径不支持此功能",
|
||||||
|
"prefs.blur_guard": "已降低模糊质量,以保持画面响应。模糊半径未改变。",
|
||||||
|
"prefs.blur_off": "关闭",
|
||||||
|
"prefs.blur_reduce_transparency": "已被系统的降低透明度设置禁用",
|
||||||
|
"prefs.blur_software": "软件图形渲染不支持此功能 (gpu-software)",
|
||||||
|
"prefs.blur_source": "此工作区视图不支持此功能",
|
||||||
|
"prefs.blur_web": "此网页渲染路径不支持此功能",
|
||||||
"prefs.bodies_folders": "实体文件夹",
|
"prefs.bodies_folders": "实体文件夹",
|
||||||
"prefs.bodies_folders_always": "始终显示",
|
"prefs.bodies_folders_always": "始终显示",
|
||||||
"prefs.bodies_folders_never": "不显示",
|
"prefs.bodies_folders_never": "不显示",
|
||||||
@@ -2650,6 +2658,10 @@
|
|||||||
"prefs.theme_follow_off": "关闭",
|
"prefs.theme_follow_off": "关闭",
|
||||||
"prefs.theme_follow_stop": "停止跟随",
|
"prefs.theme_follow_stop": "停止跟随",
|
||||||
"prefs.theme_import_hint": "单击选择主题文件,拖放到此处,或复制主题后按 Ctrl+V(Mac 上为 Cmd+V)",
|
"prefs.theme_import_hint": "单击选择主题文件,拖放到此处,或复制主题后按 Ctrl+V(Mac 上为 Cmd+V)",
|
||||||
|
"prefs.theme_notice_dismiss": "忽略",
|
||||||
|
"prefs.theme_option_blur": "面板背后模糊",
|
||||||
|
"prefs.theme_option_blur_help": "半透明面板背后的模糊程度,以物理像素为单位。0 表示关闭。",
|
||||||
|
"prefs.theme_option_invalid": "{option} 的已保存值无效;正在使用主题默认值。已保存值将予以保留。",
|
||||||
"prefs.theme_option_opacity": "不透明度",
|
"prefs.theme_option_opacity": "不透明度",
|
||||||
"prefs.theme_option_opacity_help": "调整浮动面板的不透明度。最低不透明度限制可确保文字在视口颜色上清晰可读。",
|
"prefs.theme_option_opacity_help": "调整浮动面板的不透明度。最低不透明度限制可确保文字在视口颜色上清晰可读。",
|
||||||
"prefs.theme_option_tab_colours": "选项卡颜色",
|
"prefs.theme_option_tab_colours": "选项卡颜色",
|
||||||
@@ -2658,6 +2670,7 @@
|
|||||||
"prefs.theme_option_tab_fade_ms_help": "切换选项卡时渐变面板和视口颜色。设为零可立即切换。",
|
"prefs.theme_option_tab_fade_ms_help": "切换选项卡时渐变面板和视口颜色。设为零可立即切换。",
|
||||||
"prefs.theme_options_badge": "选项",
|
"prefs.theme_options_badge": "选项",
|
||||||
"prefs.theme_options_more": "更多 · {count}",
|
"prefs.theme_options_more": "更多 · {count}",
|
||||||
|
"prefs.theme_options_newer": "此主题需要更新版本的 SolidCraft。升级后,请重新导入原始主题以恢复其选项定义。",
|
||||||
"prefs.theme_options_reset": "重置",
|
"prefs.theme_options_reset": "重置",
|
||||||
"prefs.theme_options_unavailable": "此设备不支持",
|
"prefs.theme_options_unavailable": "此设备不支持",
|
||||||
"prefs.theme_paste": "粘贴主题",
|
"prefs.theme_paste": "粘贴主题",
|
||||||
@@ -4941,6 +4954,10 @@
|
|||||||
"ui.fillet_setback_least": "须大于 {least}",
|
"ui.fillet_setback_least": "须大于 {least}",
|
||||||
"ui.fillet_setback_tip": "平滑角点曲面片开始的位置:从角点沿每条边的距离。默认值为半径的 {factor} 倍。必须大于最小后退距离(不后退时圆角相交之处;面相互垂直时即半径)。",
|
"ui.fillet_setback_tip": "平滑角点曲面片开始的位置:从角点沿每条边的距离。默认值为半径的 {factor} 倍。必须大于最小后退距离(不后退时圆角相交之处;面相互垂直时即半径)。",
|
||||||
"ui.fillet_smooth_radius": "半径平滑变化",
|
"ui.fillet_smooth_radius": "半径平滑变化",
|
||||||
|
"ui.fillet_turn": "转角",
|
||||||
|
"ui.fillet_turn_mitre": "斜接",
|
||||||
|
"ui.fillet_turn_swept": "旋转",
|
||||||
|
"ui.fillet_turn_tip": "两个圆角绕过它们之间尖锐的角边处,例如凸台根部的圆角绕过其竖直边。\n斜接:圆角继续延伸,直到以斜接相遇。\n旋转:圆角止于角边并绕它旋转,转角是与两者相切的圆环面。仅用于同一半径的圆角绕过笔直的凸角边时。",
|
||||||
"ui.fillet_type_choices_tip": "恒定半径:整条边一个半径。\n弦长:无论面以什么角度相交,圆角宽度都相同。\n可变半径:两端各一个半径,添加后沿边还有更多。\n曲率连续:与平面相接处反射没有折痕的截面 (G2)。\n样条:沿边的半径跟随您设置的控制半径。",
|
"ui.fillet_type_choices_tip": "恒定半径:整条边一个半径。\n弦长:无论面以什么角度相交,圆角宽度都相同。\n可变半径:两端各一个半径,添加后沿边还有更多。\n曲率连续:与平面相接处反射没有折痕的截面 (G2)。\n样条:沿边的半径跟随您设置的控制半径。",
|
||||||
"ui.fillet_type_curvature": "曲率连续",
|
"ui.fillet_type_curvature": "曲率连续",
|
||||||
"ui.fillet_type_tip": "横跨边的截面。曲率连续:平面之间的曲率连续 (G2) 截面,反射从面平滑过渡到圆角,没有折痕。",
|
"ui.fillet_type_tip": "横跨边的截面。曲率连续:平面之间的曲率连续 (G2) 截面,反射从面平滑过渡到圆角,没有折痕。",
|
||||||
|
|||||||
@@ -393,6 +393,8 @@ pub(crate) struct ExactSketchCurves<'a> {
|
|||||||
/// Each analytic curve's id, whether it is projected (linked) reference geometry, and its
|
/// Each analytic curve's id, whether it is projected (linked) reference geometry, and its
|
||||||
/// segments.
|
/// segments.
|
||||||
segments: Vec<(&'a str, bool, Vec<Seg2>)>,
|
segments: Vec<(&'a str, bool, Vec<Seg2>)>,
|
||||||
|
/// Each free-form curve's id (ellipses, conics, splines) and its exact segments.
|
||||||
|
free_form: Vec<(&'a str, Vec<Seg2>)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'a> ExactSketchCurves<'a> {
|
impl<'a> ExactSketchCurves<'a> {
|
||||||
@@ -400,10 +402,17 @@ impl<'a> ExactSketchCurves<'a> {
|
|||||||
/// and segment allocation must not be repeated for every generated face.
|
/// and segment allocation must not be repeated for every generated face.
|
||||||
pub(crate) fn new(sketch: &'a SolvedSketch) -> Self {
|
pub(crate) fn new(sketch: &'a SolvedSketch) -> Self {
|
||||||
let mut segments = Vec::new();
|
let mut segments = Vec::new();
|
||||||
|
let mut free_form = Vec::new();
|
||||||
if sketch.sketch.curves.len() > 2000 {
|
if sketch.sketch.curves.len() > 2000 {
|
||||||
return Self { segments };
|
return Self { segments, free_form };
|
||||||
}
|
}
|
||||||
for (index, curve) in sketch.sketch.curves.iter().enumerate() {
|
for (index, curve) in sketch.sketch.curves.iter().enumerate() {
|
||||||
|
if !curve.construction
|
||||||
|
&& let Some(exact) = sketch.sketch.exact_segs(index)
|
||||||
|
{
|
||||||
|
free_form.push((curve.id.as_str(), exact));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if curve.construction
|
if curve.construction
|
||||||
|| !matches!(
|
|| !matches!(
|
||||||
curve.kind,
|
curve.kind,
|
||||||
@@ -425,7 +434,21 @@ impl<'a> ExactSketchCurves<'a> {
|
|||||||
};
|
};
|
||||||
segments.push((curve.id.as_str(), curve.link.is_some(), originals));
|
segments.push((curve.id.as_str(), curve.link.is_some(), originals));
|
||||||
}
|
}
|
||||||
Self { segments }
|
Self { segments, free_form }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The one free-form sketch curve (an ellipse, conic or spline) a generated segment is a
|
||||||
|
/// whole piece of, either way round: one of the exact segments the curve is made of. A
|
||||||
|
/// segment two curves share stays ambiguous.
|
||||||
|
pub(crate) fn find_free_form(&self, generated: &Seg2) -> Option<String> {
|
||||||
|
let reversed = generated.reversed();
|
||||||
|
let mut found: Option<Option<&str>> = None;
|
||||||
|
for (id, originals) in &self.free_form {
|
||||||
|
if originals.iter().any(|original| *original == *generated || *original == reversed) {
|
||||||
|
found = Some(if found.is_some() { None } else { Some(*id) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
found.flatten().map(str::to_owned)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The one sketch curve a generated segment lies on. A curve drawn over projected
|
/// The one sketch curve a generated segment lies on. A curve drawn over projected
|
||||||
|
|||||||
@@ -734,6 +734,10 @@ pub enum FeatureKind {
|
|||||||
/// from the vertex instead of where the rounds meet (a constant-radius fillet only).
|
/// from the vertex instead of where the rounds meet (a constant-radius fillet only).
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
setbacks: Vec<CornerSetback>,
|
setbacks: Vec<CornerSetback>,
|
||||||
|
/// How two rounds turn where they meet at a sharp side edge: in a mitre (the default) or
|
||||||
|
/// swept round it (a constant-radius, exact, rolling-corner fillet without setbacks only).
|
||||||
|
#[serde(default, skip_serializing_if = "FilletTurn::is_mitre")]
|
||||||
|
turn: FilletTurn,
|
||||||
/// Round each picked edge's tangent chain with it (the default: edges continuing it
|
/// Round each picked edge's tangent chain with it (the default: edges continuing it
|
||||||
/// smoothly at its ends, as far as they go), or only the picked edges.
|
/// smoothly at its ends, as far as they go), or only the picked edges.
|
||||||
#[serde(default = "yes", skip_serializing_if = "is_yes")]
|
#[serde(default = "yes", skip_serializing_if = "is_yes")]
|
||||||
@@ -1971,6 +1975,38 @@ impl FilletCorner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How two constant rounds of one radius turn round a sharp, straight, convex side edge square to
|
||||||
|
/// both on a plane, such as the rounds along a boss's base at its upright edges.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum FilletTurn {
|
||||||
|
/// The rounds run on to where they cross and meet in a mitre.
|
||||||
|
#[default]
|
||||||
|
Mitre,
|
||||||
|
/// The rounds end at the side edge and the corner is their section revolved about it, a
|
||||||
|
/// torus tangent to both (`solidcraft_kernel::FilletTurn::Swept`).
|
||||||
|
Swept,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a swept turn is refused with a fillet's other options: the kernel sweeps the turn of two
|
||||||
|
/// constant rounds of one radius, at its own corners only.
|
||||||
|
pub const SWEPT_TAKES: &str = "a swept corner turns two constant rounds of one radius: `turn: swept` takes `type: constant`, \
|
||||||
|
`corner: rolling`, no `setbacks` and `fit: exact` or `reduce`";
|
||||||
|
|
||||||
|
impl FilletTurn {
|
||||||
|
pub fn is_mitre(&self) -> bool {
|
||||||
|
*self == FilletTurn::Mitre
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The kernel's turn.
|
||||||
|
pub fn kernel(self) -> solidcraft_kernel::FilletTurn {
|
||||||
|
match self {
|
||||||
|
FilletTurn::Mitre => solidcraft_kernel::FilletTurn::Mitre,
|
||||||
|
FilletTurn::Swept => solidcraft_kernel::FilletTurn::Swept,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// How Extend continues a surface past its edges.
|
/// How Extend continues a surface past its edges.
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
|
|||||||
+23
-10
@@ -409,13 +409,14 @@ fn fillet_fitted(
|
|||||||
edges: &[Vec3],
|
edges: &[Vec3],
|
||||||
r: f64,
|
r: f64,
|
||||||
fit: crate::FilletFit,
|
fit: crate::FilletFit,
|
||||||
|
turn: kernel::FilletTurn,
|
||||||
) -> std::result::Result<(kernel::Body, Option<Vec<f64>>, Vec<Vec3>), kernel::KernelError> {
|
) -> std::result::Result<(kernel::Body, Option<Vec<f64>>, Vec<Vec3>), kernel::KernelError> {
|
||||||
match kernel::fillet_removing(body, edges, r) {
|
match kernel::fillet_removing(body, edges, r, turn) {
|
||||||
Ok((b, gone)) => Ok((b, None, gone)),
|
Ok((b, gone)) => Ok((b, None, gone)),
|
||||||
Err(kernel::KernelError::TooLarge(why)) if fit == crate::FilletFit::Exact => {
|
Err(kernel::KernelError::TooLarge(why)) if fit == crate::FilletFit::Exact => {
|
||||||
Err(with_largest_fit(why, "radius", || kernel::fillet_max(body, edges, r)))
|
Err(with_largest_fit(why, "radius", || kernel::fillet_max(body, edges, r, turn)))
|
||||||
}
|
}
|
||||||
Err(kernel::KernelError::TooLarge(why)) if fit == crate::FilletFit::Reduce => match kernel::fillet_max_each(body, edges, r)? {
|
Err(kernel::KernelError::TooLarge(why)) if fit == crate::FilletFit::Reduce => match kernel::fillet_max_each(body, edges, r, turn)? {
|
||||||
// Built at the radius asked for after all (the groups apart): nothing reduced.
|
// Built at the radius asked for after all (the groups apart): nothing reduced.
|
||||||
Some((used, b)) => Ok((b, used.iter().any(|u| *u < r).then_some(used), Vec::new())),
|
Some((used, b)) => Ok((b, used.iter().any(|u| *u < r).then_some(used), Vec::new())),
|
||||||
None => Err(kernel::KernelError::TooLarge(why)),
|
None => Err(kernel::KernelError::TooLarge(why)),
|
||||||
@@ -4014,6 +4015,11 @@ fn blend_body(
|
|||||||
) -> Result<(Traced, Option<Vec<f64>>, Tapered)> {
|
) -> Result<(Traced, Option<Vec<f64>>, Tapered)> {
|
||||||
let input = mb.traced();
|
let input = mb.traced();
|
||||||
let (mut used, mut tapered) = (None, Vec::new());
|
let (mut used, mut tapered) = (None, Vec::new());
|
||||||
|
if let FeatureKind::Fillet { turn: crate::FilletTurn::Swept, style, fit, corner, setbacks, .. } = &f.kind
|
||||||
|
&& (!style.is_constant() || *fit == crate::FilletFit::Taper || !corner.is_rolling() || !setbacks.is_empty())
|
||||||
|
{
|
||||||
|
return Err(DocError::Invalid(crate::SWEPT_TAKES.into()));
|
||||||
|
}
|
||||||
// The blended body, and the lineage of what the last blend started from.
|
// The blended body, and the lineage of what the last blend started from.
|
||||||
let (nb, from) = match &f.kind {
|
let (nb, from) = match &f.kind {
|
||||||
// A curvature-continuous fillet's corners are G2 patches, whatever `corner` says: SMLib's
|
// A curvature-continuous fillet's corners are G2 patches, whatever `corner` says: SMLib's
|
||||||
@@ -4061,8 +4067,8 @@ fn blend_body(
|
|||||||
tapered = edges.iter().copied().zip(tapers).filter_map(|(p, t)| Some((p, t?))).collect();
|
tapered = edges.iter().copied().zip(tapers).filter_map(|(p, t)| Some((p, t?))).collect();
|
||||||
(b, input)
|
(b, input)
|
||||||
}
|
}
|
||||||
FeatureKind::Fillet { fit, .. } => {
|
FeatureKind::Fillet { fit, turn, .. } => {
|
||||||
let (b, u, gone) = fillet_fitted(&mb.body, edges, r, *fit)?;
|
let (b, u, gone) = fillet_fitted(&mb.body, edges, r, *fit, turn.kernel())?;
|
||||||
if let Some(u) = &u {
|
if let Some(u) = &u {
|
||||||
append_warning(warning, reduced_note(u, r));
|
append_warning(warning, reduced_note(u, r));
|
||||||
}
|
}
|
||||||
@@ -4820,8 +4826,13 @@ fn centered_extrude(
|
|||||||
};
|
};
|
||||||
// Fit cap surfaces in their own coordinates. Fitting a distant world-space
|
// Fit cap surfaces in their own coordinates. Fitting a distant world-space
|
||||||
// wire can tilt an otherwise exact planar cap through numeric cancellation.
|
// wire can tilt an otherwise exact planar cap through numeric cancellation.
|
||||||
// A side drawn in collinear pieces is one face (#1788).
|
// A side drawn in collinear pieces is one face (#1788), and so is a side along one ellipse
|
||||||
let bodies = kernel::extrude_whole_sides(&Plane::XY, std::slice::from_ref(region), start, end)?;
|
// or spline, whatever pieces the profile holds it in.
|
||||||
|
let curves = sketch.map(crate::config::ExactSketchCurves::new);
|
||||||
|
let one_curve = |s: &solidcore_record::Seg2, t: &solidcore_record::Seg2| {
|
||||||
|
curves.as_ref().is_some_and(|c| c.find_free_form(s).is_some_and(|id| c.find_free_form(t).is_some_and(|other| other == id)))
|
||||||
|
};
|
||||||
|
let bodies = kernel::extrude_whole_sides(&Plane::XY, std::slice::from_ref(region), start, end, &one_curve)?;
|
||||||
if *plane == Plane::XY {
|
if *plane == Plane::XY {
|
||||||
return Ok(bodies.into_iter().map(|body| roles.tool(body, None)).collect());
|
return Ok(bodies.into_iter().map(|body| roles.tool(body, None)).collect());
|
||||||
}
|
}
|
||||||
@@ -6318,12 +6329,12 @@ fn replay(
|
|||||||
let nb = match &src.kind {
|
let nb = match &src.kind {
|
||||||
FeatureKind::Chamfer { distance2, angle, flip, .. } => chamfer_with(vals, &mb.body, &moved, r, distance2, angle, *flip)?,
|
FeatureKind::Chamfer { distance2, angle, flip, .. } => chamfer_with(vals, &mb.body, &moved, r, distance2, angle, *flip)?,
|
||||||
// A copy is congruent to the original: the radius it was reduced to first.
|
// A copy is congruent to the original: the radius it was reduced to first.
|
||||||
FeatureKind::Fillet { fit, .. } => match st.fitted.get(&src.id) {
|
FeatureKind::Fillet { fit, turn, .. } => match st.fitted.get(&src.id) {
|
||||||
Some(used) => {
|
Some(used) => {
|
||||||
let used: Vec<f64> = idx.iter().map(|i| used.get(*i).copied().unwrap_or(r)).collect();
|
let used: Vec<f64> = idx.iter().map(|i| used.get(*i).copied().unwrap_or(r)).collect();
|
||||||
kernel::fillet_each(&mb.body, &moved, &used)?
|
kernel::fillet_each(&mb.body, &moved, &used, turn.kernel())?
|
||||||
}
|
}
|
||||||
None => fillet_fitted(&mb.body, &moved, r, *fit)?.0,
|
None => fillet_fitted(&mb.body, &moved, r, *fit, turn.kernel())?.0,
|
||||||
},
|
},
|
||||||
_ => kernel::fillet(&mb.body, &moved, r)?,
|
_ => kernel::fillet(&mb.body, &moved, r)?,
|
||||||
};
|
};
|
||||||
@@ -8545,6 +8556,7 @@ mod chain_tests {
|
|||||||
fit: Default::default(),
|
fit: Default::default(),
|
||||||
corner: Default::default(),
|
corner: Default::default(),
|
||||||
setbacks: Vec::new(),
|
setbacks: Vec::new(),
|
||||||
|
turn: Default::default(),
|
||||||
tangent_chain: true,
|
tangent_chain: true,
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
@@ -8573,6 +8585,7 @@ mod chain_tests {
|
|||||||
fit: Default::default(),
|
fit: Default::default(),
|
||||||
corner: Default::default(),
|
corner: Default::default(),
|
||||||
setbacks: Vec::new(),
|
setbacks: Vec::new(),
|
||||||
|
turn: Default::default(),
|
||||||
tangent_chain,
|
tangent_chain,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+208
-22
@@ -103,6 +103,11 @@ enum End {
|
|||||||
/// At (or within half a pitch of) an end of the face where material goes on past it, a
|
/// At (or within half a pitch of) an end of the face where material goes on past it, a
|
||||||
/// shoulder, a head, a blind hole's bottom: the groove stops short, clear of it.
|
/// shoulder, a head, a blind hole's bottom: the groove stops short, clear of it.
|
||||||
Closed,
|
Closed,
|
||||||
|
/// At an end of an external thread's face where a shoulder starts and stays beyond the face's
|
||||||
|
/// end: the groove runs on through the plane of that end and is trimmed there, so the root
|
||||||
|
/// reaches the shoulder at every angle, a nut bottoms out on it, and nothing past the plane
|
||||||
|
/// is cut.
|
||||||
|
Shoulder,
|
||||||
/// Inside the face: the groove stops square there.
|
/// Inside the face: the groove stops square there.
|
||||||
Inside,
|
Inside,
|
||||||
}
|
}
|
||||||
@@ -121,6 +126,9 @@ pub(super) struct ThreadOn {
|
|||||||
/// A modelled internal thread in a hole wider than it needs: the radius the bore is narrowed
|
/// 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.
|
/// to (the thread's minor radius) before the groove is cut, with the hole's own radius.
|
||||||
narrow: Option<f64>,
|
narrow: Option<f64>,
|
||||||
|
/// A shoulder keeps the square stop because the kernel couldn't check whether it stays beyond
|
||||||
|
/// the face's end.
|
||||||
|
shoulder_unchecked: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The free-end test of a face's two ends (`side` 0 at its start, 1 at its end), each made
|
/// The free-end test of a face's two ends (`side` 0 at its start, 1 at its end), each made
|
||||||
@@ -160,6 +168,22 @@ impl FaceEnds<'_> {
|
|||||||
}
|
}
|
||||||
Ok(free)
|
Ok(free)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Does the shoulder at this closed end stay beyond the plane of the face's end? Not where it
|
||||||
|
/// rises back along the face (a slanted plate, a boss on a round body), which a groove
|
||||||
|
/// running to that plane would cut into: there is material outside the face just inside it.
|
||||||
|
/// The caller keeps the square stop, and says so, when the kernel can't make the check.
|
||||||
|
fn clear_of_end_plane(&self, side: usize) -> Result<bool> {
|
||||||
|
#[cfg(test)]
|
||||||
|
if tests::FAIL_CLEARANCE.get() {
|
||||||
|
return Err(kernel::KernelError::Failed("clearance refused for the test".into()).into());
|
||||||
|
}
|
||||||
|
let (p, e) = (self.pitch, self.pitch * 1e-3);
|
||||||
|
let inside = if side == 0 { (self.cyl.start + e, self.cyl.start + p) } else { (self.cyl.end - p, self.cyl.end - e) };
|
||||||
|
// All the radii the groove cuts beyond the face; only touching their rims is not in its
|
||||||
|
// way, as in `free`.
|
||||||
|
kernel::material_in_ring(self.body, self.cyl, self.beyond.0 + e, self.beyond.1 - e, inside).map(|hit| !hit).map_err(DocError::from)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A thread on the cylindrical face at `p`: its size, and the part of the face it runs over.
|
/// A thread on the cylindrical face at `p`: its size, and the part of the face it runs over.
|
||||||
@@ -333,11 +357,23 @@ pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk)
|
|||||||
// How each end of the thread meets the face: at a face end, or within half a pitch (and its
|
// How each end of the thread meets the face: at a face end, or within half a pitch (and its
|
||||||
// runout) of one, where the groove would reach past it, that end's free-end test decides.
|
// runout) of one, where the groove would reach past it, that end's free-end test decides.
|
||||||
let near = |at: f64, side: usize| if side == 0 { at - lo } else { hi - at } < pitch / 2.0 + ease.get(side).copied().unwrap_or(0.0);
|
let near = |at: f64, side: usize| if side == 0 { at - lo } else { hi - at } < pitch / 2.0 + ease.get(side).copied().unwrap_or(0.0);
|
||||||
|
// A shoulder whose check the kernel couldn't make keeps the square stop, and the feature says so.
|
||||||
|
let mut unchecked = false;
|
||||||
let mut kind = |at: f64, side: usize| -> Result<End> {
|
let mut kind = |at: f64, side: usize| -> Result<End> {
|
||||||
Ok(if !ask.modeled || !near(at, side) {
|
Ok(if !ask.modeled || !near(at, side) {
|
||||||
End::Inside
|
End::Inside
|
||||||
} else if !ends.free(side)? {
|
} else if !ends.free(side)? {
|
||||||
End::Closed
|
let at_end = (if side == 0 { at - lo } else { hi - at }) < pitch * 1e-3;
|
||||||
|
let square = ease.get(side).copied().unwrap_or(0.0) == 0.0;
|
||||||
|
let clear = at_end && square && !cyl.internal && !form.is_taper() && {
|
||||||
|
let check = ends.clear_of_end_plane(side);
|
||||||
|
unchecked |= matches!(check, Err(DocError::Kernel(_)));
|
||||||
|
match check {
|
||||||
|
Err(DocError::Kernel(_)) => false,
|
||||||
|
other => other?,
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if clear { End::Shoulder } else { End::Closed }
|
||||||
} else if (if side == 0 { at - lo } else { hi - at }) < pitch * 1e-3 {
|
} else if (if side == 0 { at - lo } else { hi - at }) < pitch * 1e-3 {
|
||||||
End::Free
|
End::Free
|
||||||
} else {
|
} else {
|
||||||
@@ -373,7 +409,7 @@ pub(super) fn thread_on(st: &ModelState, feature: u64, p: Vec3, ask: &ThreadAsk)
|
|||||||
.then(|| form.groove(major, pitch, true))
|
.then(|| form.groove(major, pitch, true))
|
||||||
.filter(|g| !(g.root_r - cyl.radius >= g.basic_depth / 4.0))
|
.filter(|g| !(g.root_r - cyl.radius >= g.basic_depth / 4.0))
|
||||||
.map(|g| g.crest_r);
|
.map(|g| g.crest_r);
|
||||||
Ok(ThreadOn { info, cyl, ends, steep: ask.steep, ease, from: side, narrow })
|
Ok(ThreadOn { info, cyl, ends, steep: ask.steep, ease, from: side, narrow, shoulder_unchecked: unchecked })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The end of the face (0: its start, 1: its end) a thread of limited length starts at: the end
|
/// The end of the face (0: its start, 1: its end) a thread of limited length starts at: the end
|
||||||
@@ -536,7 +572,9 @@ fn thread_grooves(
|
|||||||
// Past a free end of the face the groove runs out into the air, a pitch beyond it. Before
|
// Past a free end of the face the groove runs out into the air, a pitch beyond it. Before
|
||||||
// a closed one it stops with its section clear of the face's end (its centre as far inside
|
// a closed one it stops with its section clear of the face's end (its centre as far inside
|
||||||
// as the section reaches that way), so it never cuts into the shoulder there; inside the
|
// as the section reaches that way), so it never cuts into the shoulder there; inside the
|
||||||
// face it stops square at the threaded length's end.
|
// face it stops square at the threaded length's end. At a shoulder it can be trimmed against
|
||||||
|
// ([`End::Shoulder`]) its whole section runs past the plane of the face's end, so every angle
|
||||||
|
// of the last turn is cut down to that plane ([`trim_at_shoulders`] takes off the rest).
|
||||||
//
|
//
|
||||||
// A runout `e` at an end (ISO 3508) fades the groove out over `e`: its section moves out of
|
// A runout `e` at an end (ISO 3508) fades the groove out over `e`: its section moves out of
|
||||||
// the material (an external thread's away from the axis, past the surface; an internal
|
// the material (an external thread's away from the axis, past the surface; an internal
|
||||||
@@ -548,12 +586,14 @@ fn thread_grooves(
|
|||||||
(End::Free, false) => t.start - p,
|
(End::Free, false) => t.start - p,
|
||||||
(End::Free, true) => t.start,
|
(End::Free, true) => t.start,
|
||||||
(End::Closed, _) => (t.start - ease[0]).max(face.0 + w_lo),
|
(End::Closed, _) => (t.start - ease[0]).max(face.0 + w_lo),
|
||||||
|
(End::Shoulder, _) => face.0 - w_hi - 0.1 * p,
|
||||||
(End::Inside, _) => t.start - ease[0],
|
(End::Inside, _) => t.start - ease[0],
|
||||||
};
|
};
|
||||||
let s1 = match (ends[1], ease[1] > 0.0) {
|
let s1 = match (ends[1], ease[1] > 0.0) {
|
||||||
(End::Free, false) => t.end + p,
|
(End::Free, false) => t.end + p,
|
||||||
(End::Free, true) => t.end,
|
(End::Free, true) => t.end,
|
||||||
(End::Closed, _) => (t.end + ease[1]).min(face.1 - w_hi),
|
(End::Closed, _) => (t.end + ease[1]).min(face.1 - w_hi),
|
||||||
|
(End::Shoulder, _) => face.1 + w_lo + 0.1 * p,
|
||||||
(End::Inside, _) => t.end + ease[1],
|
(End::Inside, _) => t.end + ease[1],
|
||||||
};
|
};
|
||||||
if !(s1 - s0 > p * 1e-3) {
|
if !(s1 - s0 > p * 1e-3) {
|
||||||
@@ -698,6 +738,24 @@ fn taper_envelope(t: &ThreadInfo, radius: f64, ends: [End; 2], (d0, from): (f64,
|
|||||||
Ok(ring.pop())
|
Ok(ring.pop())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The groove with what runs past a shoulder's plane taken off ([`End::Shoulder`]), so cutting it
|
||||||
|
/// leaves the shoulder whole.
|
||||||
|
fn trim_at_shoulders(mut tool: Body, axis: Vec3, ends: [End; 2], face: (f64, f64), k: UnitVec3) -> Result<Body> {
|
||||||
|
for (end, at, keep_positive) in [(ends[0], face.0, true), (ends[1], face.1, false)] {
|
||||||
|
if end != End::Shoulder {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let plane = Plane::from_normal(axis + k * at, *k).ok_or_else(|| DocError::Invalid("thread axis".into()))?;
|
||||||
|
// An error of the cut, like the kernel's own: `cut` then falls back to the square stop.
|
||||||
|
let missed = || kernel::KernelError::Failed("the thread's groove misses its face".into());
|
||||||
|
let kept = kernel::half_space(&tool, &plane, keep_positive)?.0;
|
||||||
|
#[cfg(test)]
|
||||||
|
let kept = kept.filter(|_| !tests::EMPTY_TRIM.get());
|
||||||
|
tool = kept.ok_or_else(missed)?;
|
||||||
|
}
|
||||||
|
Ok(tool)
|
||||||
|
}
|
||||||
|
|
||||||
/// Evaluate a thread feature: a cosmetic thread is listed in the model's threads, a modelled
|
/// Evaluate a thread feature: a cosmetic thread is listed in the model's threads, a modelled
|
||||||
/// one cut into its body.
|
/// one cut into its body.
|
||||||
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
|
pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st: &mut ModelState, warning: &mut Option<String>) -> Result<()> {
|
||||||
@@ -768,7 +826,13 @@ pub(super) fn eval(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature,
|
|||||||
None => note,
|
None => note,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
cut(st, on, (*tip, angle, depth))
|
if let Some(note) = cut(st, on, (*tip, angle, depth))? {
|
||||||
|
*warning = Some(match warning.take() {
|
||||||
|
Some(w) => format!("{w}; {note}"),
|
||||||
|
None => note,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A runout as (mm, pitches): ISO 3508's standard 2.5 P and short 1.25 P, or a length.
|
/// A runout as (mm, pitches): ISO 3508's standard 2.5 P and short 1.25 P, or a length.
|
||||||
@@ -783,9 +847,10 @@ pub(super) fn runout(vals: &BTreeMap<String, Value>, e: &Option<crate::ThreadRun
|
|||||||
|
|
||||||
/// Cut a modelled thread resolved on its face into its body: the grooves (SMLib's exact screw
|
/// Cut a modelled thread resolved on its face into its body: the grooves (SMLib's exact screw
|
||||||
/// sweeps, a taper's crests by its envelope) by one B-rep boolean, then the chamfered tip
|
/// 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).
|
/// (`tip`, with its angle and depth when given). What the user should be told about the result, if
|
||||||
pub(super) fn cut(st: &mut ModelState, on: ThreadOn, (tip, angle, depth): (ThreadTip, Option<f64>, Option<f64>)) -> Result<()> {
|
/// anything, is returned.
|
||||||
let ThreadOn { info: t, cyl, ends, steep, ease, from: from_side, narrow } = on;
|
pub(super) fn cut(st: &mut ModelState, on: ThreadOn, (tip, angle, depth): (ThreadTip, Option<f64>, Option<f64>)) -> Result<Option<String>> {
|
||||||
|
let ThreadOn { info: t, cyl, ends, steep, ease, from: from_side, narrow, shoulder_unchecked } = on;
|
||||||
let i = st.bodies.iter().position(|b| b.name == t.body).ok_or_else(|| DocError::Invalid("threaded body".into()))?;
|
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 Some(mb) = st.bodies.get(i).cloned() else { return Err(DocError::Invalid("body".into())) };
|
||||||
let (mut input, radius) = (mb.traced(), cyl.radius);
|
let (mut input, radius) = (mb.traced(), cyl.radius);
|
||||||
@@ -822,21 +887,46 @@ pub(super) fn cut(st: &mut ModelState, on: ThreadOn, (tip, angle, depth): (Threa
|
|||||||
}
|
}
|
||||||
false => None,
|
false => None,
|
||||||
};
|
};
|
||||||
let mut grooves = Vec::new();
|
let cut_grooves = |ends: [End; 2]| -> Result<(Option<Body>, usize)> {
|
||||||
for g in thread_grooves(&t, groove_radius, (cyl.start, cyl.end), ends, steep, ease, taper)? {
|
let mut grooves = Vec::new();
|
||||||
let section = Region2 { outer: solidcore_record::Loop2::polygon(&g.section), holes: Vec::new() };
|
for g in thread_grooves(&t, groove_radius, (cyl.start, cyl.end), ends, steep, ease, taper)? {
|
||||||
grooves.push(kernel::sweep_helix_radial(&g.plane, §ion, g.base, g.axis, g.pitch, g.turns, &g.law)?);
|
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)?);
|
||||||
if let Some(taper) = taper
|
}
|
||||||
&& let Some(envelope) = taper_envelope(&t, radius, ends, taper)?
|
if let Some(taper) = taper
|
||||||
{
|
&& let Some(envelope) = taper_envelope(&t, radius, ends, taper)?
|
||||||
grooves.push(envelope);
|
{
|
||||||
}
|
grooves.push(envelope);
|
||||||
let tool = match grooves.as_slice() {
|
}
|
||||||
[one] => one.clone(),
|
let tool = match grooves.as_slice() {
|
||||||
many => kernel::union_all(&many.iter().collect::<Vec<_>>())?,
|
[one] => one.clone(),
|
||||||
|
many => kernel::union_all(&many.iter().collect::<Vec<_>>())?,
|
||||||
|
};
|
||||||
|
let tool = trim_at_shoulders(tool, t.axis_point, ends, (cyl.start, cyl.end), t.axis)?;
|
||||||
|
let cut = kernel::boolean(&input.body, &tool, BoolOp::Cut)?;
|
||||||
|
// Now rather than at the feature's end, where it could only fail the feature.
|
||||||
|
if ends.contains(&End::Shoulder)
|
||||||
|
&& let Some(body) = &cut
|
||||||
|
{
|
||||||
|
kernel::check_kept(body)?;
|
||||||
|
}
|
||||||
|
Ok((cut, tool.face_count()))
|
||||||
};
|
};
|
||||||
let (nb, groove_faces) = (kernel::boolean(&input.body, &tool, BoolOp::Cut)?, tool.face_count());
|
// A design that built with the square stop keeps building: where the kernel can't make the
|
||||||
|
// trimmed cut, the groove stops clear of the shoulder as it did, and the feature says so.
|
||||||
|
let mut made = cut_grooves(ends);
|
||||||
|
let mut square_stop = shoulder_unchecked;
|
||||||
|
if matches!(made, Err(DocError::Kernel(_))) && ends.contains(&End::Shoulder) {
|
||||||
|
made = cut_grooves(ends.map(|e| if e == End::Shoulder { End::Closed } else { e }));
|
||||||
|
square_stop = true;
|
||||||
|
}
|
||||||
|
let note = (square_stop && made.is_ok()).then(|| {
|
||||||
|
format!(
|
||||||
|
"the {} thread's groove could not be run down to the shoulder here, so it stops clear of it and its last turn is left uncut",
|
||||||
|
t.designation
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let (nb, groove_faces) = made?;
|
||||||
let nb = nb.ok_or_else(|| DocError::Invalid("the thread removed everything".into()))?;
|
let nb = nb.ok_or_else(|| DocError::Invalid("the thread removed everything".into()))?;
|
||||||
// The groove's faces have no role to name them by.
|
// The groove's faces have no role to name them by.
|
||||||
let groove = crate::lineage::Lineage::unnamed_faces(groove_faces);
|
let groove = crate::lineage::Lineage::unnamed_faces(groove_faces);
|
||||||
@@ -854,7 +944,7 @@ pub(super) fn cut(st: &mut ModelState, on: ThreadOn, (tip, angle, depth): (Threa
|
|||||||
if let Some(slot) = st.bodies.get_mut(i) {
|
if let Some(slot) = st.bodies.get_mut(i) {
|
||||||
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
|
*slot = ModelBody::traced_new(mb.name, nb, mb.feature);
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(note)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The chamfered tip of an external thread (ISO 4753, chamfered end CH) at the free ends `tip`
|
/// The chamfered tip of an external thread (ISO 4753, chamfered end CH) at the free ends `tip`
|
||||||
@@ -937,3 +1027,99 @@ fn tip_cones(
|
|||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::cell::Cell;
|
||||||
|
|
||||||
|
thread_local! {
|
||||||
|
/// Make [`trim_at_shoulders`] find nothing of the groove left.
|
||||||
|
pub(super) static EMPTY_TRIM: Cell<bool> = const { Cell::new(false) };
|
||||||
|
/// Make the rising-shoulder check fail as the kernel would.
|
||||||
|
pub(super) static FAIL_CLEARANCE: Cell<bool> = const { Cell::new(false) };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ask() -> ThreadAsk<'static> {
|
||||||
|
ThreadAsk {
|
||||||
|
form: None,
|
||||||
|
designation: Some("M10"),
|
||||||
|
hand: ThreadHand::Right,
|
||||||
|
starts: 1,
|
||||||
|
steep: SteepFlank::default(),
|
||||||
|
extent: Extent::Full,
|
||||||
|
from: None,
|
||||||
|
modeled: true,
|
||||||
|
ease_out: (0.0, 0.0),
|
||||||
|
tapped: false,
|
||||||
|
old_auto_size: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a thread's ends meet the face at (5, 0, z) of `body`.
|
||||||
|
fn ends_of(body: Body, z: f64) -> [End; 2] {
|
||||||
|
let mut st = ModelState::default();
|
||||||
|
st.bodies.push(ModelBody::new("Part".into(), body, 1));
|
||||||
|
thread_on(&st, 1, Vec3::new(5.0, 0.0, z), &ask()).unwrap().ends
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A shoulder that stays beyond the face's end takes the trim. One rising back along the face
|
||||||
|
/// (a slanted plate, a boss on a round body) keeps the square stop.
|
||||||
|
#[test]
|
||||||
|
fn only_a_shoulder_that_stays_beyond_the_faces_end_takes_the_trim() {
|
||||||
|
let z = Vec3::new(0.0, 0.0, 1.0);
|
||||||
|
let join = |a: &Body, b: &Body| kernel::boolean(a, b, BoolOp::Union).unwrap().unwrap();
|
||||||
|
let boss = kernel::cylinder(Vec3::new(0.0, 0.0, 2.0), z, 5.0, 26.0).unwrap();
|
||||||
|
let plate = kernel::box_solid(Vec3::new(-20.0, -20.0, -6.0), Vec3::new(20.0, 20.0, 2.0)).unwrap();
|
||||||
|
let flat = ends_of(join(&plate, &boss), 20.0);
|
||||||
|
assert!(flat.contains(&End::Shoulder), "{flat:?}");
|
||||||
|
let tilted = kernel::cylinder(Vec3::new(0.0, 0.0, 0.0), Vec3::new(0.2, 0.0, 1.0), 20.0, 6.0).unwrap();
|
||||||
|
let slanted = ends_of(join(&tilted, &boss), 20.0);
|
||||||
|
assert!(slanted.contains(&End::Closed) && !slanted.contains(&End::Shoulder), "{slanted:?}");
|
||||||
|
let round = kernel::cylinder(Vec3::new(0.0, -20.0, 0.0), Vec3::new(0.0, 1.0, 0.0), 10.0, 40.0).unwrap();
|
||||||
|
let on_round = ends_of(join(&round, &boss), 20.0);
|
||||||
|
assert!(on_round.contains(&End::Closed) && !on_round.contains(&End::Shoulder), "{on_round:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the kernel can't check the shoulder, the thread still builds with the square stop, and
|
||||||
|
/// the feature says its last turn is left uncut.
|
||||||
|
#[test]
|
||||||
|
fn a_shoulder_the_kernel_cannot_check_keeps_the_square_stop_and_says_so() {
|
||||||
|
let plate = kernel::box_solid(Vec3::new(-20.0, -20.0, 0.0), Vec3::new(20.0, 20.0, 6.0)).unwrap();
|
||||||
|
let boss = kernel::cylinder(Vec3::new(0.0, 0.0, 6.0), Vec3::new(0.0, 0.0, 1.0), 5.0, 16.0).unwrap();
|
||||||
|
let body = kernel::boolean(&plate, &boss, BoolOp::Union).unwrap().unwrap();
|
||||||
|
let mut st = ModelState::default();
|
||||||
|
st.bodies.push(ModelBody::new("Part".into(), body, 1));
|
||||||
|
FAIL_CLEARANCE.set(true);
|
||||||
|
let on = thread_on(&st, 1, Vec3::new(5.0, 0.0, 14.0), &ask());
|
||||||
|
FAIL_CLEARANCE.set(false);
|
||||||
|
let on = on.unwrap();
|
||||||
|
assert!(on.ends.contains(&End::Closed) && !on.ends.contains(&End::Shoulder), "{:?}", on.ends);
|
||||||
|
let note = cut(&mut st, on, (ThreadTip::None, None, None)).unwrap();
|
||||||
|
assert!(note.is_some_and(|n| n.contains("M10") && n.contains("left uncut")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the trim leaves nothing of the groove, a thread at a shoulder that built with the
|
||||||
|
/// square stop still builds with it, and the feature says its last turn is left uncut.
|
||||||
|
#[test]
|
||||||
|
fn a_shoulder_cut_the_kernel_refuses_falls_back_to_the_square_stop_and_says_so() {
|
||||||
|
let plate = kernel::box_solid(Vec3::new(-20.0, -20.0, 0.0), Vec3::new(20.0, 20.0, 6.0)).unwrap();
|
||||||
|
let boss = kernel::cylinder(Vec3::new(0.0, 0.0, 6.0), Vec3::new(0.0, 0.0, 1.0), 5.0, 16.0).unwrap();
|
||||||
|
let body = kernel::boolean(&plate, &boss, BoolOp::Union).unwrap().unwrap();
|
||||||
|
let build = |fail: bool| {
|
||||||
|
let mut st = ModelState::default();
|
||||||
|
st.bodies.push(ModelBody::new("Part".into(), body.clone(), 1));
|
||||||
|
let on = thread_on(&st, 1, Vec3::new(5.0, 0.0, 14.0), &ask()).unwrap();
|
||||||
|
EMPTY_TRIM.set(fail);
|
||||||
|
let note = cut(&mut st, on, (ThreadTip::None, None, None));
|
||||||
|
EMPTY_TRIM.set(false);
|
||||||
|
(note.unwrap(), kernel::measure(&st.bodies[0].body).unwrap().volume)
|
||||||
|
};
|
||||||
|
let (none, trimmed) = build(false);
|
||||||
|
assert_eq!(none, None);
|
||||||
|
let (note, square) = build(true);
|
||||||
|
assert!(note.is_some_and(|n| n.contains("M10") && n.contains("left uncut")));
|
||||||
|
// The square stop leaves about half a pitch's worth of groove uncut: well over a mm³.
|
||||||
|
assert!(square > trimmed + 1.0, "{square} vs {trimmed}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
//! A fillet saves `turn: "swept"` when two of its rounds that turn round a sharp side edge (the
|
||||||
|
//! rounds along a boss's base, at its upright edges) are swept round that edge, their corner an
|
||||||
|
//! exact torus; absent, the default `mitre`, they meet in a mitre, as designs before this change were
|
||||||
|
//! built. An older design has none and keeps its mitres. The last release refuses a design with
|
||||||
|
//! this change (the newer format number).
|
||||||
@@ -239,7 +239,10 @@ impl<'a> ProfileRoles<'a> {
|
|||||||
// Every curve the segment is drawn on (one, or several coincident or collinear ones).
|
// Every curve the segment is drawn on (one, or several coincident or collinear ones).
|
||||||
let curve = |segment: usize| -> Vec<String> {
|
let curve = |segment: usize| -> Vec<String> {
|
||||||
let (Some(curves), Some(segment)) = (curves.as_ref(), segments.get(segment)) else { return Vec::new() };
|
let (Some(curves), Some(segment)) = (curves.as_ref(), segments.get(segment)) else { return Vec::new() };
|
||||||
let drawn: Vec<String> = curves.find_all_along(segment).into_iter().map(|id| format!("side:{id}")).collect();
|
let mut drawn: Vec<String> = curves.find_all_along(segment).into_iter().map(|id| format!("side:{id}")).collect();
|
||||||
|
if drawn.is_empty() {
|
||||||
|
drawn.extend(curves.find_free_form(segment).map(|id| format!("side:{id}")));
|
||||||
|
}
|
||||||
match self.thin {
|
match self.thin {
|
||||||
// A thin wall's side beside its curve (the other side may lie on it).
|
// A thin wall's side beside its curve (the other side may lie on it).
|
||||||
Some(thin) if drawn.is_empty() => curves.find_offset(segment, &thin.curves, thin.distances),
|
Some(thin) if drawn.is_empty() => curves.find_offset(segment, &thin.curves, thin.distances),
|
||||||
|
|||||||
@@ -295,6 +295,7 @@ fn box_fillet_cut_timeline() {
|
|||||||
fit: Default::default(),
|
fit: Default::default(),
|
||||||
corner: Default::default(),
|
corner: Default::default(),
|
||||||
setbacks: Vec::new(),
|
setbacks: Vec::new(),
|
||||||
|
turn: Default::default(),
|
||||||
tangent_chain: true,
|
tangent_chain: true,
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
@@ -370,6 +371,7 @@ fn errors_are_reported_not_fatal() {
|
|||||||
fit: Default::default(),
|
fit: Default::default(),
|
||||||
corner: Default::default(),
|
corner: Default::default(),
|
||||||
setbacks: Vec::new(),
|
setbacks: Vec::new(),
|
||||||
|
turn: Default::default(),
|
||||||
tangent_chain: true,
|
tangent_chain: true,
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
@@ -867,12 +869,39 @@ fn fillet_setbacks_default_to_none_and_round_trip() {
|
|||||||
fit: Default::default(),
|
fit: Default::default(),
|
||||||
corner: Default::default(),
|
corner: Default::default(),
|
||||||
setbacks: vec![CornerSetback { vertex: Position(Vec3::new(60.0, 40.0, 20.0)), distance: "8 mm".into() }],
|
setbacks: vec![CornerSetback { vertex: Position(Vec3::new(60.0, 40.0, 20.0)), distance: "8 mm".into() }],
|
||||||
|
turn: Default::default(),
|
||||||
tangent_chain: true,
|
tangent_chain: true,
|
||||||
};
|
};
|
||||||
let back: FeatureKind = serde_json::from_value(serde_json::to_value(&set).unwrap()).unwrap();
|
let back: FeatureKind = serde_json::from_value(serde_json::to_value(&set).unwrap()).unwrap();
|
||||||
assert_eq!(back, set);
|
assert_eq!(back, set);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A fillet saved before turns reads as mitred and writes no `turn`; a swept one saves
|
||||||
|
/// `turn: "swept"` and keeps it through a save and an open; a turn of another name is refused.
|
||||||
|
#[test]
|
||||||
|
fn fillet_turn_defaults_to_mitre_round_trips_and_refuses_unknown_turns() {
|
||||||
|
let old = serde_json::json!({"type": "fillet", "edges": [[15.0, 0.0, 10.0]], "radius": "2"});
|
||||||
|
let kind: FeatureKind = serde_json::from_value(old.clone()).unwrap();
|
||||||
|
let FeatureKind::Fillet { turn, .. } = &kind else { panic!("{kind:?}") };
|
||||||
|
assert_eq!(*turn, crate::FilletTurn::Mitre);
|
||||||
|
assert!(serde_json::to_value(&kind).unwrap().get("turn").is_none());
|
||||||
|
let mut swept = old.clone();
|
||||||
|
swept["turn"] = serde_json::json!("swept");
|
||||||
|
let kind: FeatureKind = serde_json::from_value(swept).unwrap();
|
||||||
|
assert!(matches!(kind, FeatureKind::Fillet { turn: crate::FilletTurn::Swept, .. }), "{kind:?}");
|
||||||
|
assert_eq!(serde_json::to_value(&kind).unwrap()["turn"], "swept");
|
||||||
|
let mut doc = Document::new("turn");
|
||||||
|
doc.add_feature(kind.clone(), None).unwrap();
|
||||||
|
let saved = doc.to_json();
|
||||||
|
let opened = Document::from_json(&saved).unwrap();
|
||||||
|
assert_eq!(opened.features.last().map(|f| &f.kind), Some(&kind));
|
||||||
|
assert_eq!(opened.to_json(), saved);
|
||||||
|
let mut unknown = old;
|
||||||
|
unknown["turn"] = serde_json::json!("round");
|
||||||
|
let e = serde_json::from_value::<FeatureKind>(unknown).unwrap_err().to_string();
|
||||||
|
assert!(e.contains("unknown variant `round`") && e.contains("mitre") && e.contains("swept"), "{e}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn timeline_group_legacy_json_omits_empty_joint_membership() {
|
fn timeline_group_legacy_json_omits_empty_joint_membership() {
|
||||||
let old = serde_json::json!({"id":100, "name":"Folder", "parent":null, "features":[]});
|
let old = serde_json::json!({"id":100, "name":"Folder", "parent":null, "features":[]});
|
||||||
@@ -1056,3 +1085,68 @@ fn feature_kind_key_and_type_name_mappings() {
|
|||||||
assert_eq!(FeatureKind::type_name_to_kind_key("MeshFeature"), Some("mesh_import"));
|
assert_eq!(FeatureKind::type_name_to_kind_key("MeshFeature"), Some("mesh_import"));
|
||||||
assert_eq!(FeatureKind::type_name_to_kind_key("NonExistent"), None);
|
assert_eq!(FeatureKind::type_name_to_kind_key("NonExistent"), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An extruded ellipse has one side face, and a spline in a profile one wall, each named by its
|
||||||
|
/// sketch curve alone (no piece number): the same names on a second build and after reopening.
|
||||||
|
#[test]
|
||||||
|
fn an_extruded_ellipse_or_spline_is_one_wall_named_by_its_curve() {
|
||||||
|
let extrude = |sketch: u64| FeatureKind::Extrude {
|
||||||
|
thin: None,
|
||||||
|
sketch: crate::SketchId(sketch),
|
||||||
|
profiles: ProfileSel::All,
|
||||||
|
extent: Extent {
|
||||||
|
convention: crate::ExtrudeConvention::LegacyPerSide,
|
||||||
|
distance: "20".into(),
|
||||||
|
direction: Direction::Positive,
|
||||||
|
distance2: None,
|
||||||
|
start_offset: None,
|
||||||
|
through_all: false,
|
||||||
|
taper: None,
|
||||||
|
corner: Default::default(),
|
||||||
|
to: None,
|
||||||
|
to_offset: None,
|
||||||
|
from: None,
|
||||||
|
to_body_apart: false,
|
||||||
|
},
|
||||||
|
operation: Operation::NewBody,
|
||||||
|
targets: vec![],
|
||||||
|
};
|
||||||
|
for spline in [false, true] {
|
||||||
|
let mut sk = Sketch::new();
|
||||||
|
let curve = if spline {
|
||||||
|
let pts: Vec<usize> =
|
||||||
|
[(0., 0.), (10., -3.), (20., 4.), (30., 0.)].iter().map(|&(x, y)| sk.add_point(Vec2::new(x, y), None).unwrap()).collect();
|
||||||
|
let (a, b) = (sk.add_point(Vec2::new(30., 20.), None).unwrap(), sk.add_point(Vec2::new(0., 20.), None).unwrap());
|
||||||
|
let curve = sk
|
||||||
|
.add_curve(solidcraft_sketch::CurveKind::Spline { pts: pts.clone(), control: false, degree: 3, periodic: false, nurbs: None }, None)
|
||||||
|
.unwrap();
|
||||||
|
sk.add_line_pts(pts[3], a, None).unwrap();
|
||||||
|
sk.add_line_pts(a, b, None).unwrap();
|
||||||
|
sk.add_line_pts(b, pts[0], None).unwrap();
|
||||||
|
curve
|
||||||
|
} else {
|
||||||
|
let m = sk.add_point(Vec2::new(10., 0.), None).unwrap();
|
||||||
|
sk.add_curve(solidcraft_sketch::CurveKind::Ellipse { c: 0, m, r: 5. }, None).unwrap()
|
||||||
|
};
|
||||||
|
let id = sk.curves[curve].id.clone();
|
||||||
|
let mut doc = Document::new("one wall");
|
||||||
|
let s = doc.add_feature(FeatureKind::Sketch { plane: PlaneRef::Origin { name: "XY".into() }, sketch: sk }, None).unwrap();
|
||||||
|
let f = doc.add_feature(extrude(s), None).unwrap();
|
||||||
|
let names = |doc: &Document| {
|
||||||
|
let mut m = Model::new();
|
||||||
|
m.evaluate(doc);
|
||||||
|
assert!(m.results.iter().all(|r| r.error.is_none()), "{:?}", m.results);
|
||||||
|
let st = m.state();
|
||||||
|
let mut names = crate::naming::face_names(&st.bodies[0]).as_ref().clone();
|
||||||
|
names.sort();
|
||||||
|
names
|
||||||
|
};
|
||||||
|
let first = names(&doc);
|
||||||
|
let wall = format!("F{f}:side:{id}");
|
||||||
|
assert_eq!(first.iter().filter(|n| **n == wall).count(), 1, "{first:?}");
|
||||||
|
assert_eq!(first.len(), if spline { 6 } else { 3 }, "{first:?}");
|
||||||
|
assert!(first.iter().all(|n| !n.contains('#')), "no piece names: {first:?}");
|
||||||
|
assert_eq!(names(&doc), first, "a second build");
|
||||||
|
assert_eq!(names(&Document::from_json(&doc.to_json()).unwrap()), first, "reopened");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ impl Walk for solidcraft_kernel::DraftCorner {}
|
|||||||
impl Walk for crate::document::Direction {}
|
impl Walk for crate::document::Direction {}
|
||||||
impl Walk for crate::document::FilletFit {}
|
impl Walk for crate::document::FilletFit {}
|
||||||
impl Walk for crate::document::FilletCorner {}
|
impl Walk for crate::document::FilletCorner {}
|
||||||
|
impl Walk for crate::document::FilletTurn {}
|
||||||
impl Walk for crate::document::MeshData {}
|
impl Walk for crate::document::MeshData {}
|
||||||
impl Walk for crate::document::RevolveConvention {}
|
impl Walk for crate::document::RevolveConvention {}
|
||||||
impl Walk for crate::document::RailFit {}
|
impl Walk for crate::document::RailFit {}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ use solidcraft_geom::prelude::*;
|
|||||||
use super::{CommandSpec, not_in_sketch};
|
use super::{CommandSpec, not_in_sketch};
|
||||||
use crate::params::{UNKNOWN_COMMAND, bad, bool_, expr, expr_problem, req_expr, str_, string_list, string_list_or_default, vec2, vec3};
|
use crate::params::{UNKNOWN_COMMAND, bad, bool_, expr, expr_problem, req_expr, str_, string_list, string_list_or_default, vec2, vec3};
|
||||||
use crate::schema::features::{
|
use crate::schema::features::{
|
||||||
BoundingSolidParams, BoxParams, ConeParams, CylinderParams, ExtrudeParams, FeatureNotice, FeatureResult, NoticeAction, OffsetPlaneParams,
|
AppliedRadius, BoundingSolidParams, BoxParams, ConeParams, CylinderParams, ExtrudeParams, FeatureNotice, FeatureResult, FilletParams,
|
||||||
PipeParams, PlaneAtAngleParams, ScaleParams, SphereParams, TorusParams,
|
FilletResult, NoticeAction, OffsetPlaneParams, PipeParams, PlaneAtAngleParams, ScaleParams, SphereParams, TorusParams,
|
||||||
};
|
};
|
||||||
use crate::schema::{TypedContract, output_schema_for};
|
use crate::schema::{TypedContract, output_schema_for};
|
||||||
use crate::{EngineError, Result, Session};
|
use crate::{EngineError, Result, Session};
|
||||||
@@ -30,6 +30,7 @@ const BOUNDING_SOLID_CONTRACT: TypedContract<BoundingSolidParams> = TypedContrac
|
|||||||
const SCALE_CONTRACT: TypedContract<ScaleParams> = TypedContract::new();
|
const SCALE_CONTRACT: TypedContract<ScaleParams> = TypedContract::new();
|
||||||
const OFFSET_PLANE_CONTRACT: TypedContract<OffsetPlaneParams> = TypedContract::new();
|
const OFFSET_PLANE_CONTRACT: TypedContract<OffsetPlaneParams> = TypedContract::new();
|
||||||
const PLANE_AT_ANGLE_CONTRACT: TypedContract<PlaneAtAngleParams> = TypedContract::new();
|
const PLANE_AT_ANGLE_CONTRACT: TypedContract<PlaneAtAngleParams> = TypedContract::new();
|
||||||
|
const FILLET_CONTRACT: TypedContract<FilletParams> = TypedContract::new();
|
||||||
|
|
||||||
pub static COMMANDS: &[CommandSpec] = &[
|
pub static COMMANDS: &[CommandSpec] = &[
|
||||||
CommandSpec::new("solid.extrude", "Extrude", crate::checked!("solid.extrude", extrude))
|
CommandSpec::new("solid.extrude", "Extrude", crate::checked!("solid.extrude", extrude))
|
||||||
@@ -78,11 +79,13 @@ pub static COMMANDS: &[CommandSpec] = &[
|
|||||||
.contract(&CONE_CONTRACT)
|
.contract(&CONE_CONTRACT)
|
||||||
.output(output_schema_for::<FeatureResult>)
|
.output(output_schema_for::<FeatureResult>)
|
||||||
.params("radius | diameter, height: expr (the base circle, and the top's distance along the axis; a negative height goes the other way); top_radius? | top_diameter?: expr (the top circle; default 0: a full cone ending in an apex); base?: [x,y,z] (centre of the base circle: numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); operation?, touching?, targets?: [body], name?, body_name?"),
|
.params("radius | diameter, height: expr (the base circle, and the top's distance along the axis; a negative height goes the other way); top_radius? | top_diameter?: expr (the top circle; default 0: a full cone ending in an apex); base?: [x,y,z] (centre of the base circle: numbers in mm or length expressions; default: the origin); axis?: X|Y|Z|[x,y,z] (default Z); operation?, touching?, targets?: [body], name?, body_name?"),
|
||||||
CommandSpec::new("solid.fillet", "Fillet", fillet)
|
CommandSpec::new("solid.fillet", "Fillet", crate::checked!("solid.fillet", fillet))
|
||||||
.at("SOLID", "MODIFY")
|
.at("SOLID", "MODIFY")
|
||||||
.icon("fillet")
|
.icon("fillet")
|
||||||
.key("F")
|
.key("F")
|
||||||
.params("edges: [[x,y,z] point on edge | {body, index}] ; radius: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is rounded with it; false: exactly the picked edges); body?; type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one planar end face at each end (including sloped ends); radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; a curvature round's corners are always g2 patches, between its own sections; g2: each corner where three or more rounded edges meet, and every edge there is rounded, is set back (1.5 × the radius along each edge unless `setbacks` names it) and filled with a patch meeting the rounds and the faces round it to the second order (curvature-continuous seams, clear of the points where a round's section ends on a face), constant-radius exact fillets only, refused where it cannot be built); setbacks?: [{vertex: [x,y,z], distance: expr}, …] (corner setbacks: the corner at the vertex nearest `vertex`, where every edge meeting it is rounded, starts its patch `distance` along each of those edges from the vertex, past where the rounds would meet (past the radius at a square corner), so the rounds blend through a larger patch; one distance for all of a corner's edges; constant-radius exact fillets only; with `corner: g2`, the G2 patch's corners); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused, naming the largest that fits; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"),
|
.contract(&FILLET_CONTRACT)
|
||||||
|
.output(output_schema_for::<FilletResult>)
|
||||||
|
.params("edges: [[x,y,z] point on edge | {body, index}] ; radius: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is rounded with it; false: exactly the picked edges); body?; type?: constant|curvature (alias g2: curvature-continuous quintic between planar faces along straight edges, one planar end face at each end (including sloped ends); radius sets the equivalent circular setback, not constant curvature; exact fit only)|chord (radius is the width across)|variable (radius at the end nearest start, radius2 at the other; one end may be 0, where the round closes to the corner)|spline (the radius along the edge is a clamped B-spline from the end nearest start: radius is its first control radius, radii the rest, knots its knot vector from 0 to 1; one end may be 0); radius2?; start?: [x,y,z]; radii?: [expr, …]; knots?: [number, …]; mid?: [[position 0…1 from start, radius expr], …] (variable radii between the ends); smooth?: bool (smooth instead of straight between the radii); corner?: rolling|g2 (rolling, the default: the corners where three blended edges meet are pieces of the rolling ball's sphere, tangent to each blend; a curvature round's corners are always g2 patches, between its own sections; g2: each corner where three or more rounded edges meet, and every edge there is rounded, is set back (1.5 × the radius along each edge unless `setbacks` names it) and filled with a patch meeting the rounds and the faces round it to the second order (curvature-continuous seams, clear of the points where a round's section ends on a face), constant-radius exact fillets only, refused where it cannot be built); setbacks?: [{vertex: [x,y,z], distance: expr}, …] (corner setbacks: the corner at the vertex nearest `vertex`, where every edge meeting it is rounded, starts its patch `distance` along each of those edges from the vertex, past where the rounds would meet (past the radius at a square corner), so the rounds blend through a larger patch; one distance for all of a corner's edges; constant-radius exact fillets only; with `corner: g2`, the G2 patch's corners); turn?: mitre|swept (where two rounds turn round a sharp, straight, convex side edge square to both on a plane, such as the rounds along a boss's base at its upright edges: mitre, the default, runs them on to meet in a mitre; swept ends them at the side edge and fills the corner with their section revolved about it, a torus; swept takes type: constant, corner: rolling, no setbacks and fit: exact or reduce, and is refused with the reason where the rounds are unequal or the side edge is concave, curved or tangent); fit?: exact|reduce|taper (exact, the default: a radius too large for the geometry around the edges is refused, naming the largest that fits; reduce: where it doesn't fit it is reduced to the largest that does, one radius for edges sharing a face; the smallest used is returned as `radius_applied`, each edge's as `radii_applied: [{edge: [x,y,z], radius}]`, and noted in the feature's warning; taper: along each straight edge between planar faces where it doesn't fit, the radius follows the room there as a smooth spline, down to 0 where a face closes to a point, noted in the feature's warning)"),
|
||||||
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}] ; distance: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is cut with it; false: exactly the picked edges); distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
|
CommandSpec::new("solid.chamfer", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}] ; distance: expr; tangent_chain?: bool (default true: each picked edge's tangent chain is cut with it; false: exactly the picked edges); distance2?: expr (along the second face) | angle?: expr (from the first face); flip?: bool (which face is first: by default the one facing up most); body?"),
|
||||||
CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; touching?: separate|one_body (what a join makes of pieces that share no face: separate, the default, makes each piece that touches another only along an edge or at a point a body of its own and keeps pieces that touch nothing in one body; one_body keeps every piece in one body); keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"),
|
CommandSpec::new("solid.combine", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; touching?: separate|one_body (what a join makes of pieces that share no face: separate, the default, makes each piece that touches another only along an edge or at a point a body of its own and keeps pieces that touch nothing in one body; one_body keeps every piece in one body); keep_tools?: bool; new_component?: bool (the result goes into a new component; component_name?)"),
|
||||||
CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect)
|
CommandSpec::new("solid.pattern.rectangular", "Rectangular Pattern", pattern_rect)
|
||||||
@@ -1788,6 +1791,15 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
return Err(bad(cmd, "`setbacks` are built for a constant-radius fillet (`type: constant`, `fit: exact`) only"));
|
return Err(bad(cmd, "`setbacks` are built for a constant-radius fillet (`type: constant`, `fit: exact`) only"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// How two rounds turn at a sharp side edge.
|
||||||
|
let turn = match str_(p, "turn").map(str::to_ascii_lowercase).as_deref() {
|
||||||
|
None | Some("mitre") => solidcraft_doc::FilletTurn::Mitre,
|
||||||
|
Some("swept") => solidcraft_doc::FilletTurn::Swept,
|
||||||
|
Some(o) => return Err(bad(cmd, format!("unknown turn `{o}` (mitre or swept)"))),
|
||||||
|
};
|
||||||
|
if !turn.is_mitre() && (!style.is_constant() || fit == solidcraft_doc::FilletFit::Taper || !corner.is_rolling() || !setbacks.is_empty()) {
|
||||||
|
return Err(bad(cmd, solidcraft_doc::SWEPT_TAKES));
|
||||||
|
}
|
||||||
let mut out = add_feature(
|
let mut out = add_feature(
|
||||||
s,
|
s,
|
||||||
p,
|
p,
|
||||||
@@ -1799,6 +1811,7 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
fit,
|
fit,
|
||||||
corner,
|
corner,
|
||||||
setbacks,
|
setbacks,
|
||||||
|
turn,
|
||||||
tangent_chain: bool_(p, "tangent_chain").unwrap_or(true),
|
tangent_chain: bool_(p, "tangent_chain").unwrap_or(true),
|
||||||
},
|
},
|
||||||
)?;
|
)?;
|
||||||
@@ -1808,9 +1821,9 @@ fn fillet(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
&& let Some(r) = state.fitted_radius(id)
|
&& let Some(r) = state.fitted_radius(id)
|
||||||
{
|
{
|
||||||
out["radius_applied"] = serde_json::json!(r);
|
out["radius_applied"] = serde_json::json!(r);
|
||||||
let each: Vec<Value> =
|
let each: Vec<AppliedRadius> =
|
||||||
state.fitted_edges(id).unwrap_or_default().into_iter().map(|(p, r)| serde_json::json!({"edge": [p.x, p.y, p.z], "radius": r})).collect();
|
state.fitted_edges(id).unwrap_or_default().into_iter().map(|(p, radius)| AppliedRadius { edge: [p.x, p.y, p.z], radius }).collect();
|
||||||
out["radii_applied"] = Value::Array(each);
|
out["radii_applied"] = serde_json::to_value(each).map_err(|e| EngineError::Internal(cmd.into(), e.to_string()))?;
|
||||||
}
|
}
|
||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
@@ -3567,6 +3580,87 @@ mod corner_tests {
|
|||||||
eprintln!("mixed corner g2: {err}");
|
eprintln!("mixed corner g2: {err}");
|
||||||
assert!(err.contains("not supported yet") && err.contains("(40.000, 20.000, 20.000)") && err.contains("all convex or all concave"), "{err}");
|
assert!(err.contains("not supported yet") && err.contains("(40.000, 20.000, 20.000)") && err.contains("all convex or all concave"), "{err}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `solid.fillet turn?: mitre|swept` on the four base edges of a 10 x 10 x 5 boss on a
|
||||||
|
/// 40 x 30 x 10 plate, r 1: at each upright boss edge the two rounds turn round it. Each base
|
||||||
|
/// edge gains the spandrel A = (1 - π/4) r² along its 10, and the corners the rounds' part
|
||||||
|
/// beyond both walls, whose section's first moment about the upright edge is
|
||||||
|
/// M = (5/6 - π/4) r³: mitred (the default), 8 M; swept, that section revolved a quarter turn at
|
||||||
|
/// each corner, 2π M, the corner's face the horn torus of radii r, r about the upright edge,
|
||||||
|
/// centred r above the plate. The turn is saved; an unknown turn and a swept chord round are
|
||||||
|
/// refused as parameters, and swept rounds along a pocket's floor (its upright edges concave)
|
||||||
|
/// with the kernel's reason.
|
||||||
|
#[test]
|
||||||
|
fn rounds_turning_round_a_boss_edge_are_mitred_or_swept() {
|
||||||
|
use std::f64::consts::PI;
|
||||||
|
const BASE: [[f64; 3]; 4] = [[20.0, 10.0, 10.0], [25.0, 15.0, 10.0], [20.0, 20.0, 10.0], [15.0, 15.0, 10.0]];
|
||||||
|
let boss = || {
|
||||||
|
let mut s = Session::default();
|
||||||
|
run(&mut s, "solid.box", &json!({"length": 40, "width": 30, "height": 10, "body_name": "B"}));
|
||||||
|
run(&mut s, "solid.box", &json!({"corner": [15, 10, 10], "length": 10, "width": 10, "height": 5, "operation": "join"}));
|
||||||
|
s
|
||||||
|
};
|
||||||
|
let turned = |turn: Option<&str>| {
|
||||||
|
let mut s = boss();
|
||||||
|
let mut p = json!({"edges": BASE, "radius": 1});
|
||||||
|
if let Some(t) = turn {
|
||||||
|
p["turn"] = json!(t);
|
||||||
|
}
|
||||||
|
let reply = run(&mut s, "solid.fillet", &p);
|
||||||
|
assert_eq!(reply["warning"], json!(null), "{reply}");
|
||||||
|
let body = s.world_state().unwrap().body("B").unwrap().body.clone();
|
||||||
|
assert!(body.validity().is_empty(), "{:?}", body.validity());
|
||||||
|
(s, solidcraft_kernel::measure(&body).unwrap(), body)
|
||||||
|
};
|
||||||
|
let (mitred, mitre, _) = turned(None);
|
||||||
|
let (_, named, _) = turned(Some("mitre"));
|
||||||
|
let (swept_session, swept, body) = turned(Some("swept"));
|
||||||
|
eprintln!("mitre: {} faces, {:.9}; swept: {} faces, {:.9}", mitre.faces, mitre.volume, swept.faces, swept.volume);
|
||||||
|
let r: f64 = 1.0;
|
||||||
|
let (a, m) = ((1.0 - PI / 4.0) * r * r, (5.0 / 6.0 - PI / 4.0) * r * r * r);
|
||||||
|
let near = |v: f64, want: f64| (v - want).abs() < 1e-9 * want;
|
||||||
|
assert!(near(mitre.volume, 12500.0 + 40.0 * a + 8.0 * m), "{}", mitre.volume);
|
||||||
|
assert!(near(swept.volume, 12500.0 + 40.0 * a + 2.0 * PI * m), "{}", swept.volume);
|
||||||
|
assert_eq!(named.volume.to_bits(), mitre.volume.to_bits(), "mitre is the default");
|
||||||
|
assert_eq!((mitre.faces, swept.faces), (15, 19));
|
||||||
|
// The four corner faces (the record's torus is a ring torus, so a horn torus is its exact
|
||||||
|
// rational form): every point on the horn torus about its upright edge.
|
||||||
|
let mut corners = 0;
|
||||||
|
for f in &body.record().faces {
|
||||||
|
if !matches!(f.surface, solidcore_record::brep::Surface::RationalSpline { .. }) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
corners += 1;
|
||||||
|
let eval = solidcraft_geom::brep::eval::SurfaceEval::new(&f.surface).unwrap();
|
||||||
|
let [[u0, u1], [v0, v1]] = f.domain;
|
||||||
|
for i in 0..=4 {
|
||||||
|
for j in 0..=4 {
|
||||||
|
let p = eval.point([u0 + (u1 - u0) * f64::from(i) / 4.0, v0 + (v1 - v0) * f64::from(j) / 4.0]).unwrap();
|
||||||
|
let (cx, cy) = (if p.x < 20.0 { 15.0 } else { 25.0 }, if p.y < 15.0 { 10.0 } else { 20.0 });
|
||||||
|
let rho = (p.x - cx).hypot(p.y - cy);
|
||||||
|
let off = ((rho - r).powi(2) + (p.z - 10.0 - r).powi(2)).sqrt() - r;
|
||||||
|
assert!(off.abs() < 1e-9, "{p:?} is {off} off the horn torus");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(corners, 4);
|
||||||
|
assert!(!mitred.doc.to_json().contains("\"turn\""));
|
||||||
|
assert!(swept_session.doc.to_json().contains("\"turn\": \"swept\""));
|
||||||
|
let mut s = boss();
|
||||||
|
let e = s.execute("solid.fillet", &json!({"edges": BASE, "radius": 1, "turn": "round"})).unwrap_err().to_string();
|
||||||
|
assert!(e.contains("unknown turn `round` (mitre or swept)"), "{e}");
|
||||||
|
let e = s.execute("solid.fillet", &json!({"edges": BASE, "radius": 1, "turn": "swept", "type": "chord"})).unwrap_err().to_string();
|
||||||
|
assert!(e.contains(solidcraft_doc::SWEPT_TAKES), "{e}");
|
||||||
|
// A pocket 5 deep in the plate, its floor's edges rounded: the rounds meet at its concave
|
||||||
|
// upright edges.
|
||||||
|
let mut s = Session::default();
|
||||||
|
run(&mut s, "solid.box", &json!({"length": 40, "width": 30, "height": 10, "body_name": "P"}));
|
||||||
|
run(&mut s, "solid.box", &json!({"corner": [10, 10, 5], "length": 20, "width": 10, "height": 6, "operation": "cut"}));
|
||||||
|
let rim = json!([[20, 10, 5], [30, 15, 5], [20, 20, 5], [10, 15, 5]]);
|
||||||
|
let e = s.execute("solid.fillet", &json!({"edges": rim, "radius": 1, "turn": "swept"})).unwrap_err().to_string();
|
||||||
|
eprintln!("swept along a pocket's rim: {e}");
|
||||||
|
assert!(e.contains("swept corner at (") && e.contains("round that edge at a radius of at least 1 first"), "{e}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "fillet_connected_rounds_tests.rs"]
|
#[path = "fillet_connected_rounds_tests.rs"]
|
||||||
|
|||||||
@@ -98,8 +98,53 @@ fn a_trays_faces_have_readable_sheet_names_that_follow_its_size() {
|
|||||||
assert_eq!(again, names);
|
assert_eq!(again, names);
|
||||||
let right = point(&s, &format!("sheet:Body1:F{sd}.1:top"));
|
let right = point(&s, &format!("sheet:Body1:F{sd}.1:top"));
|
||||||
assert!((right.x - 399.0).abs() < 1e-3, "{right:?}");
|
assert!((right.x - 399.0).abs() < 1e-3, "{right:?}");
|
||||||
let far = point(&s, &format!("sheet:Body1:F{fb}.1:rim.far"));
|
let rim = format!("sheet:Body1:F{fb}.1:rim.far");
|
||||||
assert!((199.0 - 1e-3..=200.0 + 1e-3).contains(&far.y) && (0.0..=400.0).contains(&far.x) && (far.z - 30.0).abs() < 1e-3, "{far:?}");
|
let far = point(&s, &rim);
|
||||||
|
let st = s.model.state();
|
||||||
|
let hit = solidcraft_doc::naming::locate_one(&st, None, &rim).unwrap();
|
||||||
|
on_planar_face(&st.bodies[hit.body].body, hit.face, far);
|
||||||
|
assert!((far.z - 30.0).abs() < 1e-9 && (199.0..=200.0).contains(&far.y), "the back flange's far rim: {far:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `p` lies on face `face` of `body`, on its exact geometry: within rounding at the part's scale
|
||||||
|
/// of the plane the face lies on, and inside the boundary its edges trace there (each a line,
|
||||||
|
/// counted by crossings).
|
||||||
|
fn on_planar_face(body: &solidcraft_kernel::Body, face: usize, p: Vec3) {
|
||||||
|
use solidcore_record::brep::{Curve, Loop};
|
||||||
|
let rounding = 1e-12 * body.size().max(1.0);
|
||||||
|
let surface = solidcraft_kernel::face_surface(body, face).unwrap();
|
||||||
|
let on = surface.nearest(p).unwrap();
|
||||||
|
assert!(on.dist(p) <= rounding, "{p:?} is {} off face {face}'s surface", on.dist(p));
|
||||||
|
let solidcraft_kernel::FaceSurface::Plane { normal, .. } = surface else { panic!("face {face} is not planar: {surface:?}") };
|
||||||
|
// Coordinates in the plane: along `x` and `y`, square to its normal.
|
||||||
|
let x = if normal.cross(Vec3::X).len() > 0.5 { normal.cross(Vec3::X).normalized().unwrap() } else { normal.cross(Vec3::Y).normalized().unwrap() };
|
||||||
|
let y = normal.cross(x);
|
||||||
|
let local = |q: Vec3| ((q - p).dot(x), (q - p).dot(y));
|
||||||
|
let record = body.record();
|
||||||
|
let point = |v: u32| {
|
||||||
|
let q = record.vertices[v as usize].point;
|
||||||
|
Vec3::new(q[0], q[1], q[2])
|
||||||
|
};
|
||||||
|
// Crossings of the ray from `p` along +x with the face's boundary.
|
||||||
|
let mut crossings = 0;
|
||||||
|
for l in &record.faces[face].loops {
|
||||||
|
let Loop::Edges { edges } = l else { continue };
|
||||||
|
for e in edges {
|
||||||
|
let edge = &record.edges[e.edge as usize];
|
||||||
|
assert!(matches!(edge.parameters(), Curve::Line { .. }), "an edge of face {face} is not a line: {:?}", edge.parameters());
|
||||||
|
let [a, b] = edge.vertices.map(|v| local(point(v)));
|
||||||
|
let distance = {
|
||||||
|
let (dx, dy) = (b.0 - a.0, b.1 - a.1);
|
||||||
|
let t = (-(a.0 * dx + a.1 * dy) / (dx * dx + dy * dy)).clamp(0.0, 1.0);
|
||||||
|
(a.0 + t * dx).hypot(a.1 + t * dy)
|
||||||
|
};
|
||||||
|
assert!(distance > rounding, "{p:?} is on the boundary of face {face}");
|
||||||
|
if (a.1 > 0.0) != (b.1 > 0.0) && a.0 + (b.0 - a.0) * (0.0 - a.1) / (b.1 - a.1) > 0.0 {
|
||||||
|
crossings += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!(crossings % 2, 1, "{p:?} is outside face {face}'s boundary");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use super::types::{
|
use super::types::{
|
||||||
AngleExpr, AxisDir, AxisLine, ConventionParam, DirectionParam, ExtentKeyword, ExtrudeOperation, FacesParam, FeatureCommon, FromPoint,
|
AngleExpr, AxisDir, AxisLine, ConventionParam, DirectionParam, EdgeRefs, ExtentKeyword, ExtrudeOperation, FacesParam, FeatureCommon,
|
||||||
GeoRefParam, IdOrName, LengthExpr, NumberExpr, OperationParam, PlaneCommon, PlaneOrFace, Point3, PointExpr3, ProfilesParam, ScaleFactors,
|
FilletCornerParam, FilletFitParam, FilletTurnParam, FilletTypeParam, FromPoint, GeoRefParam, IdOrName, LengthExpr, NumberExpr, OperationParam,
|
||||||
SketchRef, StringList, ThinSideParam, ToPoint, TouchingParam, feature_common, present,
|
PlaneCommon, PlaneOrFace, Point3, PointExpr3, ProfilesParam, ScaleFactors, SketchRef, StringList, ThinSideParam, ToPoint, TouchingParam,
|
||||||
|
feature_common, present,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Add a box primitive.
|
/// Add a box primitive.
|
||||||
@@ -432,6 +433,92 @@ pub struct PlaneAtAngleParams {
|
|||||||
pub common: PlaneCommon,
|
pub common: PlaneCommon,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Round edges of a body: a constant radius by default, or a chord, a curvature-continuous
|
||||||
|
/// section or a radius varying along each edge (`type`).
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
#[schemars(deny_unknown_fields)]
|
||||||
|
pub struct FilletParams {
|
||||||
|
/// The edges to round: a point on each, or `{body, index}`.
|
||||||
|
pub edges: EdgeRefs,
|
||||||
|
/// The radius; the chord's width with `type: chord`, the equivalent circular setback with
|
||||||
|
/// `type: curvature`, the start radius with `type: variable` or `spline`.
|
||||||
|
pub radius: LengthExpr,
|
||||||
|
/// The body whose edges the points pick. Default: whichever body has an edge there.
|
||||||
|
#[serde(default)]
|
||||||
|
pub body: Option<String>,
|
||||||
|
/// Round each picked edge's tangent chain with it. Default: true; false rounds exactly the
|
||||||
|
/// picked edges.
|
||||||
|
#[serde(default)]
|
||||||
|
pub tangent_chain: Option<bool>,
|
||||||
|
#[serde(default, rename = "type")]
|
||||||
|
pub kind: Option<FilletTypeParam>,
|
||||||
|
/// `type: variable`: the radius at the end away from `start`; it or `radius` may be 0, where
|
||||||
|
/// the round closes to the corner.
|
||||||
|
#[serde(default)]
|
||||||
|
pub radius2: Option<LengthExpr>,
|
||||||
|
/// `type: variable` or `spline`: a point near the end the radius starts from.
|
||||||
|
#[serde(default)]
|
||||||
|
pub start: Option<Point3>,
|
||||||
|
/// `type: variable`: radii between the ends, each `[position, radius]`, the position between 0
|
||||||
|
/// and 1 from the start end. At most 100 are read.
|
||||||
|
#[serde(default)]
|
||||||
|
pub mid: Option<Vec<(f64, LengthExpr)>>,
|
||||||
|
/// `type: variable`: smooth instead of straight between the radii. Default: false.
|
||||||
|
#[serde(default)]
|
||||||
|
pub smooth: Option<bool>,
|
||||||
|
/// `type: spline`: the control radii after `radius`. At most 100 are read.
|
||||||
|
#[serde(default)]
|
||||||
|
pub radii: Option<Vec<LengthExpr>>,
|
||||||
|
/// `type: spline`: the knot vector from 0 to 1, as many knots as all control radii, the
|
||||||
|
/// degree (1 to 3) and one more.
|
||||||
|
#[serde(default)]
|
||||||
|
pub knots: Option<Vec<f64>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub fit: Option<FilletFitParam>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub corner: Option<FilletCornerParam>,
|
||||||
|
/// Corner setbacks: the corner at the vertex nearest each `vertex`, where every edge meeting it
|
||||||
|
/// is rounded, starts its patch `distance` along each of those edges from the vertex, past
|
||||||
|
/// where the rounds would meet. Constant-radius exact fillets only.
|
||||||
|
#[serde(default)]
|
||||||
|
pub setbacks: Option<Vec<SetbackParam>>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub turn: Option<FilletTurnParam>,
|
||||||
|
#[serde(flatten, deserialize_with = "feature_common")]
|
||||||
|
pub common: FeatureCommon,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A fillet corner set back.
|
||||||
|
#[derive(Debug, Deserialize, JsonSchema)]
|
||||||
|
pub struct SetbackParam {
|
||||||
|
/// A point at the corner's vertex.
|
||||||
|
pub vertex: Point3,
|
||||||
|
/// How far along each edge there the corner's patch starts.
|
||||||
|
pub distance: LengthExpr,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The radius `fit: reduce` gave one edge.
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub struct AppliedRadius {
|
||||||
|
/// A point on the edge.
|
||||||
|
pub edge: [f64; 3],
|
||||||
|
/// The radius it was rounded at.
|
||||||
|
pub radius: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What `solid.fillet` returns: a feature command's reply, with the radii `fit: reduce` used.
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
pub struct FilletResult {
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub feature: FeatureResult,
|
||||||
|
/// With `fit: reduce` where it reduced the radius: the smallest radius used.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub radius_applied: Option<f64>,
|
||||||
|
/// With `fit: reduce` where it reduced the radius: each edge's radius.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub radii_applied: Option<Vec<AppliedRadius>>,
|
||||||
|
}
|
||||||
|
|
||||||
/// What a feature command returns.
|
/// What a feature command returns.
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
pub struct FeatureResult {
|
pub struct FeatureResult {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
BoundingSolidParams, BoxParams, ConeParams, CylinderParams, ExtrudeParams, FeatureResult, OffsetPlaneParams, PipeParams, PlaneAtAngleParams,
|
BoundingSolidParams, BoxParams, ConeParams, CylinderParams, ExtrudeParams, FeatureResult, FilletParams, FilletResult, OffsetPlaneParams,
|
||||||
ScaleParams, SphereParams, TorusParams,
|
PipeParams, PlaneAtAngleParams, ScaleParams, SphereParams, TorusParams,
|
||||||
};
|
};
|
||||||
use crate::Session;
|
use crate::Session;
|
||||||
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms, sketch_rectangle};
|
use crate::schema::tests::{assert_keys, assert_strict_keeps_todays_errors, conforms, sketch_rectangle};
|
||||||
@@ -87,6 +87,30 @@ fn schemas_and_the_key_check_agree() {
|
|||||||
assert_keys(input_schema_for::<ScaleParams>(), &["bodies", "factor", "factors", "name", "origin", "participants"]);
|
assert_keys(input_schema_for::<ScaleParams>(), &["bodies", "factor", "factors", "name", "origin", "participants"]);
|
||||||
assert_keys(input_schema_for::<OffsetPlaneParams>(), &["base", "name", "offset", "participants", "through"]);
|
assert_keys(input_schema_for::<OffsetPlaneParams>(), &["base", "name", "offset", "participants", "through"]);
|
||||||
assert_keys(input_schema_for::<PlaneAtAngleParams>(), &["angle", "axis", "base", "name", "participants"]);
|
assert_keys(input_schema_for::<PlaneAtAngleParams>(), &["angle", "axis", "base", "name", "participants"]);
|
||||||
|
assert_keys(
|
||||||
|
input_schema_for::<FilletParams>(),
|
||||||
|
&[
|
||||||
|
"body",
|
||||||
|
"body_name",
|
||||||
|
"body_names",
|
||||||
|
"corner",
|
||||||
|
"edges",
|
||||||
|
"fit",
|
||||||
|
"knots",
|
||||||
|
"mid",
|
||||||
|
"name",
|
||||||
|
"participants",
|
||||||
|
"radii",
|
||||||
|
"radius",
|
||||||
|
"radius2",
|
||||||
|
"setbacks",
|
||||||
|
"smooth",
|
||||||
|
"start",
|
||||||
|
"tangent_chain",
|
||||||
|
"turn",
|
||||||
|
"type",
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -157,6 +181,13 @@ fn strict_keeps_todays_errors() {
|
|||||||
("construct.plane.at_angle", json!({"base": "XY", "axis": {"face": "x", "point": [0, 0, 0]}, "angle": 30})),
|
("construct.plane.at_angle", json!({"base": "XY", "axis": {"face": "x", "point": [0, 0, 0]}, "angle": 30})),
|
||||||
("construct.plane.at_angle", json!({"base": "XY", "axis": {"face": "x"}, "angle": 30})),
|
("construct.plane.at_angle", json!({"base": "XY", "axis": {"face": "x"}, "angle": 30})),
|
||||||
("construct.plane.at_angle", json!({"base": "XY", "axis": "X", "angle": true})),
|
("construct.plane.at_angle", json!({"base": "XY", "axis": "X", "angle": true})),
|
||||||
|
("solid.fillet", json!({"edges": [[0, 0, 0]], "radius": 1, "turn": "round"})),
|
||||||
|
("solid.fillet", json!({"edges": [[0, 0, 0]], "radius": 1, "turn": "ROUND"})),
|
||||||
|
("solid.fillet", json!({"edges": [[0, 0, 0]], "radius": 1, "type": "bogus"})),
|
||||||
|
("solid.fillet", json!({"edges": [[0, 0, 0]], "radius": 1, "fit": "bogus"})),
|
||||||
|
("solid.fillet", json!({"edges": [[0, 0, 0]], "radius": 1, "corner": "round"})),
|
||||||
|
("solid.fillet", json!({"edges": [], "radius": 1})),
|
||||||
|
("solid.fillet", json!({"edges": 5, "radius": 1})),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +244,47 @@ fn replies_match_the_output_schema() {
|
|||||||
assert!(!conforms(schema, schema, &bad), "{bad}");
|
assert!(!conforms(schema, schema, &bad), "{bad}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `solid.fillet` replies with `FilletResult` in each of its forms, its contract taking each: a
|
||||||
|
/// boss's base rounded with each turn and its options spelled out, a reduced round (with the radii
|
||||||
|
/// it used), chord and curvature rounds, a smooth-patch corner with setbacks, and the variable and
|
||||||
|
/// spline forms.
|
||||||
|
#[test]
|
||||||
|
fn fillet_replies_match_its_output_schema() {
|
||||||
|
let schema = output_schema_for::<FilletResult>();
|
||||||
|
let schema = schema.as_value();
|
||||||
|
// A 40 x 30 x 10 plate with a 10 x 10 x 5 boss on it, whose base edges `boss` are.
|
||||||
|
let run = |p: Value| {
|
||||||
|
let mut s = Session { param_check: ParamCheck::Warn, ..Session::default() };
|
||||||
|
s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 10, "body_name": "B"})).unwrap_or_else(|e| panic!("{e}"));
|
||||||
|
s.execute("solid.box", &json!({"corner": [15, 10, 10], "length": 10, "width": 10, "height": 5, "operation": "join"}))
|
||||||
|
.unwrap_or_else(|e| panic!("{e}"));
|
||||||
|
let r = s.execute("solid.fillet", &p).unwrap_or_else(|e| panic!("{p}: {e}"));
|
||||||
|
assert!(conforms(schema, schema, &r), "{r} doesn't match {schema}");
|
||||||
|
assert!(r.get("param_warnings").is_none(), "{p}: {r}");
|
||||||
|
r
|
||||||
|
};
|
||||||
|
let boss = json!([[20, 10, 10], [25, 15, 10], [20, 20, 10], [15, 15, 10]]);
|
||||||
|
run(
|
||||||
|
json!({"edges": boss, "radius": 1, "turn": "mitre", "type": "constant", "fit": "exact", "corner": "rolling", "tangent_chain": true, "body": "B", "name": "Round"}),
|
||||||
|
);
|
||||||
|
run(json!({"edges": boss, "radius": "1 mm", "turn": "Swept"}));
|
||||||
|
run(json!({"edges": boss, "radius": 1, "turn": "swept", "fit": "reduce"}));
|
||||||
|
let reduced = run(json!({"edges": [[20, 0, 10]], "radius": 15, "fit": "reduce"}));
|
||||||
|
assert!(reduced.get("radius_applied").is_some_and(Value::is_number) && reduced.get("radii_applied").is_some_and(Value::is_array), "{reduced}");
|
||||||
|
run(json!({"edges": [[20, 0, 10]], "radius": 1, "type": "chord"}));
|
||||||
|
run(json!({"edges": [{"body": "B", "index": 0}], "radius": 0.5, "tangent_chain": false}));
|
||||||
|
run(json!({"edges": [[20, 0, 10]], "radius": 1, "type": "curvature"}));
|
||||||
|
// Variable and spline radii: solidcore fails a variable round on a box edge at this pin (status
|
||||||
|
// 1001, as `variable_radius_on_a_box_edge`), so their forms are checked against the contract.
|
||||||
|
for p in [
|
||||||
|
json!({"edges": [[20, 0, 10]], "radius": 1, "type": "variable", "radius2": 2, "start": [0, 0, 10], "mid": [[0.5, "1.5 mm"]], "smooth": true}),
|
||||||
|
json!({"edges": [[20, 0, 10]], "radius": 1, "type": "spline", "start": [0, 0, 10], "radii": [2], "knots": [0, 0, 1, 1]}),
|
||||||
|
] {
|
||||||
|
assert!(crate::schema::Contract::check(&crate::schema::TypedContract::<FilletParams>::new(), &p).is_empty(), "{p}");
|
||||||
|
}
|
||||||
|
run(json!({"edges": [[20, 0, 10], [40, 15, 10], [40, 0, 5]], "radius": 2, "corner": "g2", "setbacks": [{"vertex": [40, 0, 10], "distance": 4}]}));
|
||||||
|
}
|
||||||
|
|
||||||
/// The feature commands converted after box and extrude answer with `FeatureResult` too, and
|
/// The feature commands converted after box and extrude answer with `FeatureResult` too, and
|
||||||
/// their contracts take every form these calls use: in Warn, a contract that refused one would
|
/// their contracts take every form these calls use: in Warn, a contract that refused one would
|
||||||
/// still run the command, so the replies are checked for warnings too.
|
/// still run the command, so the replies are checked for warnings too.
|
||||||
|
|||||||
@@ -367,7 +367,7 @@ fn issues_are_read_from_the_commands_own_contract() {
|
|||||||
assert_eq!(messages(&issues), ["unknown key `operaton`; did you mean `operation`?"]);
|
assert_eq!(messages(&issues), ["unknown key `operaton`; did you mean `operation`?"]);
|
||||||
assert_eq!(issues, box_contract().check(&json!({"length": 1, "width": 1, "height": 1, "operaton": "cut"})));
|
assert_eq!(issues, box_contract().check(&json!({"length": 1, "width": 1, "height": 1, "operaton": "cut"})));
|
||||||
// No contract, or no such command: nothing to say.
|
// No contract, or no such command: nothing to say.
|
||||||
assert!(super::issues("solid.fillet", &json!({"nonsense": 1})).is_empty());
|
assert!(super::issues("solid.chamfer", &json!({"nonsense": 1})).is_empty());
|
||||||
assert!(super::issues("no.such", &json!({"nonsense": 1})).is_empty());
|
assert!(super::issues("no.such", &json!({"nonsense": 1})).is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -464,6 +464,66 @@ fn enum_schema(values: &[&str], description: &str) -> Schema {
|
|||||||
json_schema!({"description": description, "type": "string", "enum": values})
|
json_schema!({"description": description, "type": "string", "enum": values})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A keyword a handler reads with `str_` and lowercases, refusing any other word with `unknown
|
||||||
|
/// <what> `<word>` (<expected>)`; the lowercased word is kept for the handler.
|
||||||
|
macro_rules! keyword_param {
|
||||||
|
($(#[$doc:meta])* $name:ident, $what:literal, $values:expr, $expected:literal, $description:literal) => {
|
||||||
|
$(#[$doc])*
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct $name(pub String);
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for $name {
|
||||||
|
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||||
|
let ok = |s: &str| $values.contains(&s.to_ascii_lowercase().as_str());
|
||||||
|
let unknown = |s: &str| whole(format!(concat!("unknown ", $what, " `{}` (", $expected, ")"), s.to_ascii_lowercase()));
|
||||||
|
string_matching(d, ok, $expected, unknown).map(|s| $name(s.to_ascii_lowercase()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JsonSchema for $name {
|
||||||
|
fn schema_name() -> Cow<'static, str> {
|
||||||
|
stringify!($name).into()
|
||||||
|
}
|
||||||
|
fn json_schema(_: &mut SchemaGenerator) -> Schema {
|
||||||
|
enum_schema($values, $description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
keyword_param!(
|
||||||
|
/// `solid.fillet`'s `type`.
|
||||||
|
FilletTypeParam,
|
||||||
|
"fillet type",
|
||||||
|
&["constant", "rolling_ball", "chord", "chord_length", "curvature", "g2", "variable", "spline"],
|
||||||
|
"constant, chord, variable, spline or curvature",
|
||||||
|
"The round's section: constant (the default; rolling_ball is an alias), chord (`radius` is the width across; alias chord_length), curvature (alias g2: curvature-continuous between planar faces along straight edges, `radius` the equivalent circular setback), variable (`radius` at the end nearest `start`, `radius2` at the other) or spline (the radius a clamped B-spline from `start`: `radius`, then `radii`, over `knots`). Matching ignores case."
|
||||||
|
);
|
||||||
|
keyword_param!(
|
||||||
|
/// `solid.fillet`'s `fit`.
|
||||||
|
FilletFitParam,
|
||||||
|
"fit",
|
||||||
|
&["exact", "reduce", "taper"],
|
||||||
|
"exact, reduce or taper",
|
||||||
|
"What a radius too large for the geometry around the edges does: exact (the default) refuses it naming the largest that fits, reduce reduces it where it doesn't fit, taper follows the room along straight edges between planar faces. reduce and taper take type: constant. Matching ignores case."
|
||||||
|
);
|
||||||
|
keyword_param!(
|
||||||
|
/// `solid.fillet`'s `corner`.
|
||||||
|
FilletCornerParam,
|
||||||
|
"corner",
|
||||||
|
&["rolling", "g2"],
|
||||||
|
"rolling or g2",
|
||||||
|
"What fills a corner where three rounded edges meet: rolling (the default), the rolling ball's sphere, or g2, a set-back patch meeting the rounds to the second order (type: constant, fit: exact only). Matching ignores case."
|
||||||
|
);
|
||||||
|
keyword_param!(
|
||||||
|
/// `solid.fillet`'s `turn`.
|
||||||
|
FilletTurnParam,
|
||||||
|
"turn",
|
||||||
|
&["mitre", "swept"],
|
||||||
|
"mitre or swept",
|
||||||
|
"How two rounds turn round a sharp, straight, convex side edge square to both on a plane (the rounds along a boss's base, at its upright edges): mitre (the default) runs them on to meet in a mitre; swept ends them at the side edge and fills the corner with their section revolved about it, a torus. swept takes type: constant, corner: rolling, no setbacks and fit: exact or reduce, and is refused with the reason where the rounds are unequal or the side edge is concave, curved or tangent. Matching ignores case."
|
||||||
|
);
|
||||||
|
|
||||||
/// How a feature combines with existing bodies, as `features::operation` reads it.
|
/// How a feature combines with existing bodies, as `features::operation` reads it.
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub struct OperationParam(pub Operation);
|
pub struct OperationParam(pub Operation);
|
||||||
|
|||||||
@@ -308,7 +308,6 @@ solid.coil
|
|||||||
solid.combine
|
solid.combine
|
||||||
solid.draft
|
solid.draft
|
||||||
solid.emboss
|
solid.emboss
|
||||||
solid.fillet
|
|
||||||
solid.hole
|
solid.hole
|
||||||
solid.loft
|
solid.loft
|
||||||
solid.mirror
|
solid.mirror
|
||||||
|
|||||||
@@ -512,15 +512,127 @@ fn shaft_on_collar() -> Session {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Rig 602, ladder 06/004: a thread on a shaft ran a pitch past both ends of its face, into the
|
/// Rig 602, ladder 06/004: a thread on a shaft ran a pitch past both ends of its face, into the
|
||||||
/// collar below as into the air above. At a shoulder it stops with its widest part clear of it:
|
/// collar below as into the air above. It runs down to the shoulder and is trimmed at its plane
|
||||||
/// the collar keeps its whole π 8² 10, and the groove takes (25 - w)/P turns, w its half width
|
/// (#11610): the collar keeps its whole π 8² 10, and the groove takes 25/P turns, each slice of
|
||||||
/// (a square stop inside material takes exactly its length over P, the section being symmetric).
|
/// the shaft along the axis losing the same.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_thread_stops_clear_of_a_shoulder() {
|
fn a_thread_runs_down_to_a_shoulder_and_leaves_it_whole() {
|
||||||
let mut s = shaft_on_collar();
|
let mut s = shaft_on_collar();
|
||||||
run(&mut s, "solid.thread", json!({"face": [5, 0, 25], "designation": "M10", "modeled": true}));
|
run(&mut s, "solid.thread", json!({"face": [5, 0, 25], "designation": "M10", "modeled": true}));
|
||||||
|
let want = PI * 64.0 * 10.0 + PI * 25.0 * 25.0 - 25.0 / 1.5 * ext_turn(10.0, 1.5, 5.0);
|
||||||
|
let got = exact_volume(&s, "Part");
|
||||||
|
assert!((got - want).abs() < want * 2e-4, "{got} vs {want}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The volume of a body's part between two heights along Z, within a quadrant of X and Y when
|
||||||
|
/// given (their signs).
|
||||||
|
fn volume_between(s: &Session, b: &str, (z0, z1): (f64, f64), quadrant: Option<(bool, bool)>) -> f64 {
|
||||||
|
let st = s.model.state();
|
||||||
|
let mut part = st.body(b).unwrap().body.clone();
|
||||||
|
let on = |origin: Vec3, normal: Vec3| solidcore_record::Plane::from_normal(origin, normal.normalized().unwrap()).unwrap();
|
||||||
|
let z = Vec3::new(0.0, 0.0, 1.0);
|
||||||
|
let mut cuts = vec![(on(Vec3::new(0.0, 0.0, z0), z), true), (on(Vec3::new(0.0, 0.0, z1), z), false)];
|
||||||
|
if let Some((x, y)) = quadrant {
|
||||||
|
cuts.push((on(Vec3::ZERO, Vec3::new(1.0, 0.0, 0.0)), x));
|
||||||
|
cuts.push((on(Vec3::ZERO, Vec3::new(0.0, 1.0, 0.0)), y));
|
||||||
|
}
|
||||||
|
for (plane, positive) in cuts {
|
||||||
|
part = solidcraft_kernel::half_space(&part, &plane, positive).unwrap().0.unwrap();
|
||||||
|
}
|
||||||
|
solidcraft_kernel::measure(&part).unwrap().volume
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #11610: a boss standing on a flat plate. The groove reaches the plate at every angle of the
|
||||||
|
/// last turn, wherever the helix lands on it (each height a different gap under a pitch): a thin
|
||||||
|
/// slice on the plate has lost as much as any slice of the thread, a pitch's slice as much in each
|
||||||
|
/// quadrant (the uncut block of the old stop sat in some of them), and the plate is whole. Before,
|
||||||
|
/// the last pitch kept up to a whole turn's groove uncut.
|
||||||
|
#[test]
|
||||||
|
fn a_thread_reaches_a_flat_shoulder_at_every_angle_and_leaves_it_whole() {
|
||||||
|
let (p, t, turn) = (1.5, 6.0, ext_turn(10.0, 1.5, 5.0));
|
||||||
|
let per_mm = PI * 25.0 - turn / p;
|
||||||
|
for (h, extra) in [
|
||||||
|
(16.0, json!({})),
|
||||||
|
(16.45, json!({})),
|
||||||
|
(16.6, json!({})),
|
||||||
|
(16.9, json!({})),
|
||||||
|
(17.4, json!({})),
|
||||||
|
(16.0, json!({"hand": "left"})),
|
||||||
|
(16.6, json!({"tip": "both"})),
|
||||||
|
] {
|
||||||
|
let mut s = Session::default();
|
||||||
|
run(&mut s, "solid.box", json!({"corner": [-20, -20, 0], "length": 40, "width": 40, "height": t, "body_name": "Part"}));
|
||||||
|
run(&mut s, "solid.cylinder", json!({"base": [0, 0, t], "radius": 5, "height": h, "operation": "join", "targets": ["Part"]}));
|
||||||
|
let mut ask = json!({"face": [5, 0, t + h / 2.0], "designation": "M10", "modeled": true});
|
||||||
|
if let (Some(a), Value::Object(e)) = (ask.as_object_mut(), &extra) {
|
||||||
|
a.extend(e.clone());
|
||||||
|
}
|
||||||
|
run(&mut s, "solid.thread", ask);
|
||||||
|
let name = format!("h {h} {extra}");
|
||||||
|
assert!(s.model.state().body("Part").unwrap().body.validity().is_empty(), "{name}");
|
||||||
|
let plate = volume_between(&s, "Part", (-1.0, t), None);
|
||||||
|
assert!((plate - 1600.0 * t).abs() < 1e-6 * 1600.0 * t, "{name}: the plate lost {} mm³", 1600.0 * t - plate);
|
||||||
|
let thin = volume_between(&s, "Part", (t, t + 0.1 * p), None);
|
||||||
|
let want = per_mm * 0.1 * p;
|
||||||
|
assert!((thin - want).abs() < want * 2e-3, "{name}: the slice on the plate is {thin}, not {want}");
|
||||||
|
for quadrant in [(true, true), (true, false), (false, true), (false, false)] {
|
||||||
|
let got = volume_between(&s, "Part", (t, t + p), Some(quadrant));
|
||||||
|
let want = per_mm * p / 4.0;
|
||||||
|
assert!((got - want).abs() < want * 2e-3, "{name}: {quadrant:?} of the last pitch is {got}, not {want}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #11610: where a shoulder is not flat. A fillet or a chamfer at the shaft's base stays whole, the
|
||||||
|
/// groove trimmed where the face ends above it. A shoulder that rises back along the face (a
|
||||||
|
/// slanted plate, a boss on a round body) would be cut by a groove running to the plane of the
|
||||||
|
/// face's lowest point, so the groove keeps its square stop there. Nothing here fails.
|
||||||
|
#[test]
|
||||||
|
fn a_thread_at_a_shoulder_that_is_not_flat_still_builds() {
|
||||||
|
let (p, turn) = (1.5, ext_turn(10.0, 1.5, 5.0));
|
||||||
|
let plate = |s: &mut Session| {
|
||||||
|
run(s, "solid.box", json!({"corner": [-20, -20, 0], "length": 40, "width": 40, "height": 6, "body_name": "Part"}));
|
||||||
|
run(s, "solid.cylinder", json!({"base": [0, 0, 6], "radius": 5, "height": 16, "operation": "join", "targets": ["Part"]}));
|
||||||
|
};
|
||||||
|
for finish in [("solid.fillet", json!({"edges": [[5, 0, 6]], "radius": 1})), ("solid.chamfer", json!({"edges": [[5, 0, 6]], "distance": 1}))] {
|
||||||
|
let mut s = Session::default();
|
||||||
|
plate(&mut s);
|
||||||
|
run(&mut s, finish.0, finish.1);
|
||||||
|
let (base, shaft) = (volume_between(&s, "Part", (-1.0, 7.0), None), exact_volume(&s, "Part"));
|
||||||
|
run(&mut s, "solid.thread", json!({"face": [5, 0, 15], "designation": "M10", "modeled": true}));
|
||||||
|
assert!(s.model.state().body("Part").unwrap().body.validity().is_empty(), "{}", finish.0);
|
||||||
|
let left = volume_between(&s, "Part", (-1.0, 7.0), None);
|
||||||
|
assert!((left - base).abs() < 1e-6 * base, "{}: the base lost {} mm³", finish.0, base - left);
|
||||||
|
let (got, want) = (exact_volume(&s, "Part"), shaft - 15.0 / p * turn);
|
||||||
|
assert!((got - want).abs() < want * 2e-4, "{}: {got} vs {want}", finish.0);
|
||||||
|
}
|
||||||
|
let rising = [
|
||||||
|
vec![("solid.cylinder", json!({"base": [0, 0, 0], "axis": [0.2, 0, 1], "radius": 20, "height": 6, "body_name": "Part"}))],
|
||||||
|
vec![("solid.cylinder", json!({"base": [0, -20, 0], "axis": [0, 1, 0], "radius": 10, "height": 40, "body_name": "Part"}))],
|
||||||
|
];
|
||||||
|
for cmds in rising {
|
||||||
|
let mut s = Session::default();
|
||||||
|
for (c, ask) in cmds {
|
||||||
|
run(&mut s, c, ask);
|
||||||
|
}
|
||||||
|
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 2], "radius": 5, "height": 26, "operation": "join", "targets": ["Part"]}));
|
||||||
|
let before = exact_volume(&s, "Part");
|
||||||
|
run(&mut s, "solid.thread", json!({"face": [5, 0, 20], "designation": "M10", "modeled": true}));
|
||||||
|
assert!(s.model.state().body("Part").unwrap().body.validity().is_empty());
|
||||||
|
assert!(exact_volume(&s, "Part") < before);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// #11610: the trim is for a thread that runs the whole face. A length stopping short of the
|
||||||
|
/// shoulder still stops there, however near.
|
||||||
|
#[test]
|
||||||
|
fn a_thread_of_some_length_stops_where_it_is_told_short_of_a_shoulder() {
|
||||||
let (p, w) = (1.5, ext_half_width(10.0, 1.5));
|
let (p, w) = (1.5, ext_half_width(10.0, 1.5));
|
||||||
let want = PI * 64.0 * 10.0 + PI * 25.0 * 25.0 - (25.0 - w) / p * ext_turn(10.0, p, 5.0);
|
let mut s = shaft_on_collar();
|
||||||
|
let before = exact_volume(&s, "Part");
|
||||||
|
run(&mut s, "solid.thread", json!({"face": [5, 0, 25], "designation": "M10", "length": 24.6, "modeled": true}));
|
||||||
|
// Its end is within half a pitch of the shoulder: the stop stays clear of it.
|
||||||
|
let want = before - (24.6f64).min(25.0 - w) / p * ext_turn(10.0, p, 5.0);
|
||||||
let got = exact_volume(&s, "Part");
|
let got = exact_volume(&s, "Part");
|
||||||
assert!((got - want).abs() < want * 2e-4, "{got} vs {want}");
|
assert!((got - want).abs() < want * 2e-4, "{got} vs {want}");
|
||||||
}
|
}
|
||||||
@@ -528,7 +640,7 @@ fn a_thread_stops_clear_of_a_shoulder() {
|
|||||||
/// A d10 boss (z t..t+16) on a plate t thick, threaded M10: the plate's top is the shoulder.
|
/// A d10 boss (z t..t+16) on a plate t thick, threaded M10: the plate's top is the shoulder.
|
||||||
/// The groove, with or without its runout, stays on the boss: the plate keeps all of itself
|
/// The groove, with or without its runout, stays on the boss: the plate keeps all of itself
|
||||||
/// (what lies below z = t after the boss is cut away), and without a runout the boss loses
|
/// (what lies below z = t after the boss is cut away), and without a runout the boss loses
|
||||||
/// (16 - w)/P turns, as at a collar. The owner's bug: built as the app builds it (a rounded
|
/// 16/P turns, as at a collar. The owner's bug: built as the app builds it (a rounded
|
||||||
/// plate, a circle sketched on its top face and extruded with Join), a plate thinner than 1.5 P
|
/// plate, a circle sketched on its top face and extruded with Join), a plate thinner than 1.5 P
|
||||||
/// had no material where the free-end test looked (1 to 1.5 pitches past the face), so its top
|
/// had no material where the free-end test looked (1 to 1.5 pitches past the face), so its top
|
||||||
/// was taken for a free end and the overrun cut a pitch into it.
|
/// was taken for a free end and the overrun cut a pitch into it.
|
||||||
@@ -562,10 +674,10 @@ fn a_thread_on_a_boss_never_cuts_into_the_plate_below() {
|
|||||||
exact_volume(&s, "Body1")
|
exact_volume(&s, "Body1")
|
||||||
};
|
};
|
||||||
let plate = |t: f64| (40.0 * 40.0 - (4.0 - PI) * 25.0) * t;
|
let plate = |t: f64| (40.0 * 40.0 - (4.0 - PI) * 25.0) * t;
|
||||||
let (p, w) = (1.5, ext_half_width(10.0, 1.5));
|
let p = 1.5;
|
||||||
for t in [6.0, 1.0] {
|
for t in [6.0, 1.0] {
|
||||||
let s = boss(t, json!({}));
|
let s = boss(t, json!({}));
|
||||||
let want = plate(t) + PI * 25.0 * 16.0 - (16.0 - w) / p * ext_turn(10.0, p, 5.0);
|
let want = plate(t) + PI * 25.0 * 16.0 - 16.0 / p * ext_turn(10.0, p, 5.0);
|
||||||
let got = exact_volume(&s, "Body1");
|
let got = exact_volume(&s, "Body1");
|
||||||
assert!((got - want).abs() < want * 2e-4, "plate {t}: {got} vs {want}");
|
assert!((got - want).abs() < want * 2e-4, "plate {t}: {got} vs {want}");
|
||||||
let left = plate_left(s, t);
|
let left = plate_left(s, t);
|
||||||
@@ -915,10 +1027,9 @@ fn a_chamfered_tip_tapers_the_last_teeth() {
|
|||||||
"solid.thread",
|
"solid.thread",
|
||||||
json!({"face": [5, 0, 25], "designation": "M10", "tip": "both", "chamfer_angle": "30 deg", "chamfer_depth": 0.5, "modeled": true}),
|
json!({"face": [5, 0, 25], "designation": "M10", "tip": "both", "chamfer_angle": "30 deg", "chamfer_depth": 0.5, "modeled": true}),
|
||||||
);
|
);
|
||||||
let w = ext_half_width(10.0, 1.5);
|
|
||||||
let rf = 4.5;
|
let rf = 4.5;
|
||||||
let slope = 30f64.to_radians().tan();
|
let slope = 30f64.to_radians().tan();
|
||||||
let want = PI * 64.0 * 10.0 + PI * 25.0 * w + m10_stock(10.0 + w, 35.0, |z| 5f64.min(rf + (35.0 - z) * slope));
|
let want = PI * 64.0 * 10.0 + m10_stock(10.0, 35.0, |z| 5f64.min(rf + (35.0 - z) * slope));
|
||||||
let got = exact_volume(&s, "Part");
|
let got = exact_volume(&s, "Part");
|
||||||
assert!((got - want).abs() < want * 1e-4, "{got} vs {want}");
|
assert!((got - want).abs() < want * 1e-4, "{got} vs {want}");
|
||||||
// Internal threads take a countersink instead.
|
// Internal threads take a countersink instead.
|
||||||
@@ -1004,7 +1115,8 @@ fn a_runout_fades_the_groove_out_before_the_shoulder() {
|
|||||||
let (p, w) = (1.5, ext_half_width(10.0, 1.5));
|
let (p, w) = (1.5, ext_half_width(10.0, 1.5));
|
||||||
let root = 5.0 - 5.0 * p * 3f64.sqrt() / 16.0;
|
let root = 5.0 - 5.0 * p * 3f64.sqrt() / 16.0;
|
||||||
let h = 5.0 - root + 0.05 * p;
|
let h = 5.0 - root + 0.05 * p;
|
||||||
let turns = (35.0 - 10.0 - 2.0 * w) / p;
|
// Trimmed at the bottom collar's plane, stopped clear of the top one for its runout.
|
||||||
|
let turns = (35.0 - 10.0 - w) / p;
|
||||||
for (ease, r_out) in [(json!("short"), 1.25), (json!("standard"), 2.5), (json!("1 mm"), 1.0 / 1.5)] {
|
for (ease, r_out) in [(json!("short"), 1.25), (json!("standard"), 2.5), (json!("1 mm"), 1.0 / 1.5)] {
|
||||||
let mut s = shaft_on_collar();
|
let mut s = shaft_on_collar();
|
||||||
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 35], "radius": 8, "height": 10, "operation": "join", "targets": ["Part"]}));
|
run(&mut s, "solid.cylinder", json!({"base": [0, 0, 35], "radius": 8, "height": 10, "operation": "join", "targets": ["Part"]}));
|
||||||
|
|||||||
@@ -1296,6 +1296,7 @@ mod tests {
|
|||||||
fit: Default::default(),
|
fit: Default::default(),
|
||||||
corner: Default::default(),
|
corner: Default::default(),
|
||||||
setbacks: Vec::new(),
|
setbacks: Vec::new(),
|
||||||
|
turn: Default::default(),
|
||||||
tangent_chain: true,
|
tangent_chain: true,
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
@@ -1418,6 +1419,7 @@ mod tests {
|
|||||||
fit: Default::default(),
|
fit: Default::default(),
|
||||||
corner: Default::default(),
|
corner: Default::default(),
|
||||||
setbacks: Vec::new(),
|
setbacks: Vec::new(),
|
||||||
|
turn: Default::default(),
|
||||||
tangent_chain: true,
|
tangent_chain: true,
|
||||||
},
|
},
|
||||||
None,
|
None,
|
||||||
|
|||||||
+41
-22
@@ -234,22 +234,40 @@ fn closed(input: &Body, b: Body) -> Result<Body> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How two constant rounds of one radius turn round a sharp, straight, convex side edge square to
|
||||||
|
/// both on a plane, such as the rounds along a boss's base at its upright edges.
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub enum FilletTurn {
|
||||||
|
/// The rounds run on to where they cross and meet in a mitre.
|
||||||
|
#[default]
|
||||||
|
Mitre,
|
||||||
|
/// The rounds end at the side edge and the corner is their section revolved about it, a
|
||||||
|
/// torus tangent to both. Refused where it is not built (unequal or variable rounds, a
|
||||||
|
/// concave, curved or tangent side edge, a corner of four edges), with the reason.
|
||||||
|
Swept,
|
||||||
|
}
|
||||||
|
|
||||||
/// Constant-radius fillet of the edges nearest to the given points. A radius too large for the
|
/// Constant-radius fillet of the edges nearest to the given points. A radius too large for the
|
||||||
/// faces around the edges is refused with [`KernelError::TooLarge`] ([`fillet_max`] finds the
|
/// faces around the edges is refused with [`KernelError::TooLarge`] ([`fillet_max`] finds the
|
||||||
/// largest that works).
|
/// largest that works).
|
||||||
pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
|
pub fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
|
||||||
let _span = solidcraft_profile::span("kernel", "fillet");
|
fillet_turning(body, edges, radius, FilletTurn::Mitre)
|
||||||
body.require_brep("fillet")?;
|
|
||||||
crate::blend_smlib::fillet(body, edges, radius).and_then(|b| closed(body, b))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`fillet`], with the picks whose edges are gone: where some of the rounds overflow their
|
/// [`fillet`], its rounds turning at side edges as `turn` says.
|
||||||
/// faces, those are built first, and a picked edge one of them removes whole is not rounded (the
|
fn fillet_turning(body: &Body, edges: &[Vec3], radius: f64, turn: FilletTurn) -> Result<Body> {
|
||||||
/// others round what is left of their edges).
|
|
||||||
pub fn fillet_removing(body: &Body, edges: &[Vec3], radius: f64) -> Result<(Body, Vec<Vec3>)> {
|
|
||||||
let _span = solidcraft_profile::span("kernel", "fillet");
|
let _span = solidcraft_profile::span("kernel", "fillet");
|
||||||
body.require_brep("fillet")?;
|
body.require_brep("fillet")?;
|
||||||
let (b, gone) = crate::blend_smlib::fillet_removing(body, edges, radius)?;
|
crate::blend_smlib::fillet(body, edges, radius, turn).and_then(|b| closed(body, b))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`fillet`], its rounds turning at side edges as `turn` says, with the picks whose edges are
|
||||||
|
/// gone: where some of the rounds overflow their faces, those are built first, and a picked edge
|
||||||
|
/// one of them removes whole is not rounded (the others round what is left of their edges).
|
||||||
|
pub fn fillet_removing(body: &Body, edges: &[Vec3], radius: f64, turn: FilletTurn) -> Result<(Body, Vec<Vec3>)> {
|
||||||
|
let _span = solidcraft_profile::span("kernel", "fillet");
|
||||||
|
body.require_brep("fillet")?;
|
||||||
|
let (b, gone) = crate::blend_smlib::fillet_removing(body, edges, radius, turn)?;
|
||||||
Ok((closed(body, b)?, gone))
|
Ok((closed(body, b)?, gone))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,10 +533,11 @@ fn radius_step(radius: f64) -> f64 {
|
|||||||
/// them (a refusal of a fillet feature's exact fit runs it to name the largest that fits, and
|
/// them (a refusal of a fillet feature's exact fit runs it to name the largest that fits, and
|
||||||
/// so does `fit: reduce`). Only a refusal as too large
|
/// so does `fit: reduce`). Only a refusal as too large
|
||||||
/// ([`KernelError::TooLarge`]) narrows the search: any other failure of a candidate is the
|
/// ([`KernelError::TooLarge`]) narrows the search: any other failure of a candidate is the
|
||||||
/// search's error, since a smaller radius would not be the largest that fits.
|
/// search's error, since a smaller radius would not be the largest that fits. The rounds turn at
|
||||||
pub fn fillet_max(body: &Body, edges: &[Vec3], radius: f64) -> Result<Option<(f64, Body)>> {
|
/// side edges as `turn` says.
|
||||||
|
pub fn fillet_max(body: &Body, edges: &[Vec3], radius: f64, turn: FilletTurn) -> Result<Option<(f64, Body)>> {
|
||||||
let _span = solidcraft_profile::span("kernel", "fillet_max");
|
let _span = solidcraft_profile::span("kernel", "fillet_max");
|
||||||
largest_that_fits(radius, |r| fillet(body, edges, r))
|
largest_that_fits(radius, |r| fillet_turning(body, edges, r, turn))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`fillet_max`] for an equal-distance chamfer of these edges: the largest distance below
|
/// [`fillet_max`] for an equal-distance chamfer of these edges: the largest distance below
|
||||||
@@ -566,21 +585,21 @@ fn largest_that_fits(size: f64, blend: impl Fn(f64) -> Result<Body>) -> Result<O
|
|||||||
/// each reduced to fit, are too large together, or there are too many to search (each search is
|
/// each reduced to fit, are too large together, or there are too many to search (each search is
|
||||||
/// up to 16 fillets, and this runs in the live preview). Any failure other than a refusal as too
|
/// up to 16 fillets, and this runs in the live preview). Any failure other than a refusal as too
|
||||||
/// large is the error.
|
/// large is the error.
|
||||||
pub fn fillet_max_each(body: &Body, edges: &[Vec3], radius: f64) -> Result<Option<(Vec<f64>, Body)>> {
|
pub fn fillet_max_each(body: &Body, edges: &[Vec3], radius: f64, turn: FilletTurn) -> Result<Option<(Vec<f64>, Body)>> {
|
||||||
let _span = solidcraft_profile::span("kernel", "fillet_max_each");
|
let _span = solidcraft_profile::span("kernel", "fillet_max_each");
|
||||||
const MAX_GROUPS: usize = 8;
|
const MAX_GROUPS: usize = 8;
|
||||||
const MAX_SEARCHES: usize = 2;
|
const MAX_SEARCHES: usize = 2;
|
||||||
let one = || -> Result<Option<(Vec<f64>, Body)>> { Ok(fillet_max(body, edges, radius)?.map(|(r, b)| (vec![r; edges.len()], b))) };
|
let one = || -> Result<Option<(Vec<f64>, Body)>> { Ok(fillet_max(body, edges, radius, turn)?.map(|(r, b)| (vec![r; edges.len()], b))) };
|
||||||
let Some(groups) = interacting_groups(body, edges).filter(|g| g.len() > 1 && g.len() <= MAX_GROUPS) else { return one() };
|
let Some(groups) = interacting_groups(body, edges).filter(|g| g.len() > 1 && g.len() <= MAX_GROUPS) else { return one() };
|
||||||
let mut radii = vec![radius; edges.len()];
|
let mut radii = vec![radius; edges.len()];
|
||||||
let mut searches = 0;
|
let mut searches = 0;
|
||||||
for g in &groups {
|
for g in &groups {
|
||||||
let pts: Vec<Vec3> = g.iter().filter_map(|i| edges.get(*i).copied()).collect();
|
let pts: Vec<Vec3> = g.iter().filter_map(|i| edges.get(*i).copied()).collect();
|
||||||
let r = match fillet(body, &pts, radius) {
|
let r = match fillet_turning(body, &pts, radius, turn) {
|
||||||
Ok(_) => continue,
|
Ok(_) => continue,
|
||||||
Err(KernelError::TooLarge(_)) if searches < MAX_SEARCHES => {
|
Err(KernelError::TooLarge(_)) if searches < MAX_SEARCHES => {
|
||||||
searches += 1;
|
searches += 1;
|
||||||
match fillet_max(body, &pts, radius)? {
|
match fillet_max(body, &pts, radius, turn)? {
|
||||||
Some((r, _)) => r,
|
Some((r, _)) => r,
|
||||||
None => return one(),
|
None => return one(),
|
||||||
}
|
}
|
||||||
@@ -594,7 +613,7 @@ pub fn fillet_max_each(body: &Body, edges: &[Vec3], radius: f64) -> Result<Optio
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
match fillet_each(body, edges, &radii) {
|
match fillet_each(body, edges, &radii, turn) {
|
||||||
Ok(b) => Ok(Some((radii, b))),
|
Ok(b) => Ok(Some((radii, b))),
|
||||||
Err(KernelError::TooLarge(_)) => one(),
|
Err(KernelError::TooLarge(_)) => one(),
|
||||||
Err(e) => Err(e),
|
Err(e) => Err(e),
|
||||||
@@ -603,16 +622,16 @@ pub fn fillet_max_each(body: &Body, edges: &[Vec3], radius: f64) -> Result<Optio
|
|||||||
|
|
||||||
/// The fillet of each edge at its own radius (`radii` in `edges`' order), all in one SMLib
|
/// The fillet of each edge at its own radius (`radii` in `edges`' order), all in one SMLib
|
||||||
/// blend.
|
/// blend.
|
||||||
pub fn fillet_each(body: &Body, edges: &[Vec3], radii: &[f64]) -> Result<Body> {
|
pub fn fillet_each(body: &Body, edges: &[Vec3], radii: &[f64], turn: FilletTurn) -> Result<Body> {
|
||||||
let _span = solidcraft_profile::span("kernel", "fillet_each");
|
let _span = solidcraft_profile::span("kernel", "fillet_each");
|
||||||
if radii.len() != edges.len() {
|
if radii.len() != edges.len() {
|
||||||
return Err(KernelError::Invalid("one radius per edge".into()));
|
return Err(KernelError::Invalid("one radius per edge".into()));
|
||||||
}
|
}
|
||||||
if let Some(r) = radii.first().filter(|r| radii.iter().all(|x| x.to_bits() == r.to_bits())) {
|
if let Some(r) = radii.first().filter(|r| radii.iter().all(|x| x.to_bits() == r.to_bits())) {
|
||||||
return fillet(body, edges, *r);
|
return fillet_turning(body, edges, *r, turn);
|
||||||
}
|
}
|
||||||
body.require_brep("fillet")?;
|
body.require_brep("fillet")?;
|
||||||
crate::blend_smlib::fillet_each(body, edges, radii).and_then(|b| closed(body, b))
|
crate::blend_smlib::fillet_each(body, edges, radii, turn).and_then(|b| closed(body, b))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The points' edges (each the edge a blend's pick there takes) grouped where they share a face
|
/// The points' edges (each the edge a blend's pick there takes) grouped where they share a face
|
||||||
@@ -1599,7 +1618,7 @@ mod tests {
|
|||||||
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
|
assert!(matches!(e, KernelError::TooLarge(_)), "{e}");
|
||||||
let e = e.to_string();
|
let e = e.to_string();
|
||||||
assert!(e.contains("into the hollow at (0.000, 30.000, 40.000) and open the part"), "{e}");
|
assert!(e.contains("into the hollow at (0.000, 30.000, 40.000) and open the part"), "{e}");
|
||||||
let (largest, _) = fillet_max(&case, &[top], 7.0).unwrap().unwrap();
|
let (largest, _) = fillet_max(&case, &[top], 7.0, FilletTurn::Mitre).unwrap().unwrap();
|
||||||
assert!((largest - 6.828).abs() < 1e-9, "{largest}");
|
assert!((largest - 6.828).abs() < 1e-9, "{largest}");
|
||||||
|
|
||||||
// Just short of the corner it rounds, its whole section along the 104.
|
// Just short of the corner it rounds, its whole section along the 104.
|
||||||
@@ -1613,8 +1632,8 @@ mod tests {
|
|||||||
// inner R7's arc (centre (23, 33)) only at sqrt2 (r - 9) + 7 = r, r = 13.83.
|
// inner R7's arc (centre (23, 33)) only at sqrt2 (r - 9) + 7 = r, r = 13.83.
|
||||||
let inside = Vec3::new(0.0, 30.0, 40.0);
|
let inside = Vec3::new(0.0, 30.0, 40.0);
|
||||||
assert!(fillet(&case, &[top, inside], 7.0).is_ok());
|
assert!(fillet(&case, &[top, inside], 7.0).is_ok());
|
||||||
assert!(fillet_each(&case, &[top, inside], &[13.8, 7.0]).is_ok());
|
assert!(fillet_each(&case, &[top, inside], &[13.8, 7.0], FilletTurn::Mitre).is_ok());
|
||||||
let e = fillet_each(&case, &[top, inside], &[13.9, 7.0]).unwrap_err().to_string();
|
let e = fillet_each(&case, &[top, inside], &[13.9, 7.0], FilletTurn::Mitre).unwrap_err().to_string();
|
||||||
assert!(e.contains("into the hollow at"), "{e}");
|
assert!(e.contains("into the hollow at"), "{e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ pub fn fillet_radii(body: &Body, edges: &[(Vec3, f64)]) -> Result<Body> {
|
|||||||
return Err(KernelError::Invalid("fillet size must be positive".into()));
|
return Err(KernelError::Invalid("fillet size must be positive".into()));
|
||||||
}
|
}
|
||||||
let (points, radii): (Vec<Vec3>, Vec<f64>) = edges.iter().copied().unzip();
|
let (points, radii): (Vec<Vec3>, Vec<f64>) = edges.iter().copied().unzip();
|
||||||
crate::blend_smlib::fillet_each(body, &points, &radii)
|
crate::blend_smlib::fillet_each(body, &points, &radii, crate::FilletTurn::Mitre)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ use solidcore_record::Vec3;
|
|||||||
|
|
||||||
use crate::blend_variable::RadiusSpline;
|
use crate::blend_variable::RadiusSpline;
|
||||||
use crate::body::Body;
|
use crate::body::Body;
|
||||||
use crate::{KernelError, Result};
|
use crate::{FilletTurn, KernelError, Result};
|
||||||
|
|
||||||
/// What SMLib cannot build yet.
|
/// What SMLib cannot build yet.
|
||||||
pub(crate) fn unsupported(what: &str) -> KernelError {
|
pub(crate) fn unsupported(what: &str) -> KernelError {
|
||||||
@@ -25,22 +25,24 @@ pub(crate) fn unsupported(what: &str) -> KernelError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A constant-radius round of the edges nearest `edges` ([`crate::fillet`]).
|
/// A constant-radius round of the edges nearest `edges` ([`crate::fillet`]).
|
||||||
pub(crate) fn fillet(body: &Body, edges: &[Vec3], radius: f64) -> Result<Body> {
|
/// The rounds turn at side edges as `turn` says.
|
||||||
imp::blend(body, "fillet", Kind::Round, edges, &vec![radius; edges.len()])
|
pub(crate) fn fillet(body: &Body, edges: &[Vec3], radius: f64, turn: FilletTurn) -> Result<Body> {
|
||||||
|
imp::blend(body, "fillet", Kind::Round(turn), edges, &vec![radius; edges.len()])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`fillet`], with the picks whose edges a larger, overflowing round removed whole.
|
/// [`fillet`], with the picks whose edges a larger, overflowing round removed whole.
|
||||||
pub(crate) fn fillet_removing(body: &Body, edges: &[Vec3], radius: f64) -> Result<(Body, Vec<Vec3>)> {
|
pub(crate) fn fillet_removing(body: &Body, edges: &[Vec3], radius: f64, turn: FilletTurn) -> Result<(Body, Vec<Vec3>)> {
|
||||||
let (b, dropped) = imp::blend_dropping(body, "fillet", Kind::Round, edges, &vec![radius; edges.len()])?;
|
let (b, dropped) = imp::blend_dropping(body, "fillet", Kind::Round(turn), edges, &vec![radius; edges.len()])?;
|
||||||
Ok((b, edges.iter().zip(dropped).filter(|(_, d)| *d).map(|(p, _)| *p).collect()))
|
Ok((b, edges.iter().zip(dropped).filter(|(_, d)| *d).map(|(p, _)| *p).collect()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Each edge rounded at its own radius ([`crate::fillet_each`]).
|
/// Each edge rounded at its own radius ([`crate::fillet_each`]), turning at side edges as `turn`
|
||||||
pub(crate) fn fillet_each(body: &Body, edges: &[Vec3], radii: &[f64]) -> Result<Body> {
|
/// says.
|
||||||
|
pub(crate) fn fillet_each(body: &Body, edges: &[Vec3], radii: &[f64], turn: FilletTurn) -> Result<Body> {
|
||||||
if radii.len() != edges.len() {
|
if radii.len() != edges.len() {
|
||||||
return Err(KernelError::Invalid("one radius per edge".into()));
|
return Err(KernelError::Invalid("one radius per edge".into()));
|
||||||
}
|
}
|
||||||
imp::blend(body, "fillet", Kind::Round, edges, radii)
|
imp::blend(body, "fillet", Kind::Round(turn), edges, radii)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A constant-radius round whose corners at `setbacks` (a point at each corner's vertex, and the
|
/// A constant-radius round whose corners at `setbacks` (a point at each corner's vertex, and the
|
||||||
@@ -63,7 +65,7 @@ pub(crate) struct OpenCorners {
|
|||||||
/// that would fill each corner, for the caller to fill.
|
/// that would fill each corner, for the caller to fill.
|
||||||
/// `curvature`: the rounds' sections are curvature-continuous ([`fillet_g2`]), else circular.
|
/// `curvature`: the rounds' sections are curvature-continuous ([`fillet_g2`]), else circular.
|
||||||
pub(crate) fn fillet_setback_open(body: &Body, edges: &[Vec3], radius: f64, setbacks: &[(Vec3, f64)], curvature: bool) -> Result<OpenCorners> {
|
pub(crate) fn fillet_setback_open(body: &Body, edges: &[Vec3], radius: f64, setbacks: &[(Vec3, f64)], curvature: bool) -> Result<OpenCorners> {
|
||||||
let kind = if curvature { Kind::Curvature } else { Kind::Round };
|
let kind = if curvature { Kind::Curvature } else { Kind::Round(FilletTurn::Mitre) };
|
||||||
imp::blend_setback_open(body, kind, edges, &vec![radius; edges.len()], setbacks)
|
imp::blend_setback_open(body, kind, edges, &vec![radius; edges.len()], setbacks)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +106,8 @@ pub(crate) fn picked_edges(body: &Body, picks: &[Vec3]) -> Result<(Vec<usize>, V
|
|||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
enum Kind {
|
enum Kind {
|
||||||
Round,
|
/// Constant-radius rounds, turning at side edges as the [`FilletTurn`] says.
|
||||||
|
Round(FilletTurn),
|
||||||
Curvature,
|
Curvature,
|
||||||
Chord,
|
Chord,
|
||||||
Chamfer,
|
Chamfer,
|
||||||
@@ -115,7 +118,7 @@ mod imp {
|
|||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
use solidcore_record::Vec3;
|
use solidcore_record::Vec3;
|
||||||
use solidcraft_smlib::{Blend, Brep, ChamferSecond, CheckLevel, Error as SmError};
|
use solidcraft_smlib::{Blend, Brep, ChamferSecond, CheckLevel, Error as SmError, Turn};
|
||||||
|
|
||||||
use super::Kind;
|
use super::Kind;
|
||||||
use crate::blend_variable::RadiusSpline;
|
use crate::blend_variable::RadiusSpline;
|
||||||
@@ -225,13 +228,21 @@ mod imp {
|
|||||||
/// SMLib's blend of each kind.
|
/// SMLib's blend of each kind.
|
||||||
fn blend_kind(kind: Kind) -> Blend {
|
fn blend_kind(kind: Kind) -> Blend {
|
||||||
match kind {
|
match kind {
|
||||||
Kind::Round => Blend::Round,
|
Kind::Round(_) => Blend::Round,
|
||||||
Kind::Curvature => Blend::Curvature,
|
Kind::Curvature => Blend::Curvature,
|
||||||
Kind::Chord => Blend::Chord,
|
Kind::Chord => Blend::Chord,
|
||||||
Kind::Chamfer => Blend::Chamfer,
|
Kind::Chamfer => Blend::Chamfer,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SMLib's turn of each kind of turn.
|
||||||
|
fn smlib_turn(turn: crate::FilletTurn) -> Turn {
|
||||||
|
match turn {
|
||||||
|
crate::FilletTurn::Mitre => Turn::Mitre,
|
||||||
|
crate::FilletTurn::Swept => Turn::Swept,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn blend(body: &Body, what: &str, kind: Kind, edges: &[Vec3], sizes: &[f64]) -> Result<Body> {
|
pub(super) fn blend(body: &Body, what: &str, kind: Kind, edges: &[Vec3], sizes: &[f64]) -> Result<Body> {
|
||||||
blend_dropping(body, what, kind, edges, sizes).map(|(b, _)| b)
|
blend_dropping(body, what, kind, edges, sizes).map(|(b, _)| b)
|
||||||
}
|
}
|
||||||
@@ -243,21 +254,22 @@ mod imp {
|
|||||||
sizes_ok(what, sizes)?;
|
sizes_ok(what, sizes)?;
|
||||||
let picked = resolve(body, edges)?;
|
let picked = resolve(body, edges)?;
|
||||||
let next = crate::blend::unfinished_chain(body, edges, &picked.edges, &picked.hidden)?;
|
let next = crate::blend::unfinished_chain(body, edges, &picked.edges, &picked.hidden)?;
|
||||||
if matches!(kind, Kind::Round) {
|
if matches!(kind, Kind::Round(_)) {
|
||||||
crate::blend::rounds_too_small(body, edges, &picked.edges, sizes)?;
|
crate::blend::rounds_too_small(body, edges, &picked.edges, sizes)?;
|
||||||
crate::blend::rounds_into_hollow(body, edges, &picked.edges, sizes)?;
|
crate::blend::rounds_into_hollow(body, edges, &picked.edges, sizes)?;
|
||||||
}
|
}
|
||||||
let tol = pick_tolerance(body);
|
let tol = pick_tolerance(body);
|
||||||
let Kind::Round = kind else {
|
let Kind::Round(turn) = kind else {
|
||||||
let kind = blend_kind(kind);
|
let kind = blend_kind(kind);
|
||||||
return run_with(body, what, &picked.edges, &[], |b| b.blend_dropping(kind, &picked.points, sizes, tol))
|
return run_with(body, what, &picked.edges, &[], |b| b.blend_dropping(kind, Turn::Mitre, &picked.points, sizes, tol))
|
||||||
.map(|(out, dropped, _)| (out, dropped))
|
.map(|(out, dropped, _)| (out, dropped))
|
||||||
.map_err(|e| crate::blend::with_continuation(e, next));
|
.map_err(|e| crate::blend::with_continuation(e, next));
|
||||||
};
|
};
|
||||||
// Constant-radius rounds: the body keeps what they were made on, and a later round at a
|
// Constant-radius rounds: the body keeps what they were made on, and a later round at a
|
||||||
// corner where they meet blends them again with it (crate::blend_corner).
|
// corner where they meet blends them again with it (crate::blend_corner).
|
||||||
|
let turn = smlib_turn(turn);
|
||||||
let out = match crate::blend_corner::later_corners(body, &picked.edges, sizes)? {
|
let out = match crate::blend_corner::later_corners(body, &picked.edges, sizes)? {
|
||||||
Some((later, new)) => reblend(body, what, &later).map(|(b, dropped_new)| {
|
Some((later, new)) => reblend(body, what, &later, turn).map(|(b, dropped_new)| {
|
||||||
// A picked seam has no round of its own: only the new edges can be removed.
|
// A picked seam has no round of its own: only the new edges can be removed.
|
||||||
let dropped = picked
|
let dropped = picked
|
||||||
.edges
|
.edges
|
||||||
@@ -266,7 +278,7 @@ mod imp {
|
|||||||
.collect();
|
.collect();
|
||||||
(b.with_rounds(body, new), dropped)
|
(b.with_rounds(body, new), dropped)
|
||||||
}),
|
}),
|
||||||
None => run_with(body, what, &picked.edges, &[], |b| b.blend_dropping(Blend::Round, &picked.points, sizes, tol))
|
None => run_with(body, what, &picked.edges, &[], |b| b.blend_dropping(Blend::Round, turn, &picked.points, sizes, tol))
|
||||||
.map(|(out, dropped, _)| (out.with_rounds(body, picked.edges.iter().copied().zip(sizes.iter().copied()).collect()), dropped)),
|
.map(|(out, dropped, _)| (out.with_rounds(body, picked.edges.iter().copied().zip(sizes.iter().copied()).collect()), dropped)),
|
||||||
};
|
};
|
||||||
out.map_err(|e| crate::blend::with_continuation(e, next))
|
out.map_err(|e| crate::blend::with_continuation(e, next))
|
||||||
@@ -275,8 +287,9 @@ mod imp {
|
|||||||
/// The blend `later` makes of its base, every round from the earliest meeting the new edges'
|
/// The blend `later` makes of its base, every round from the earliest meeting the new edges'
|
||||||
/// corners on and the new edges in one, with its history over `body`, the body the later
|
/// corners on and the new edges in one, with its history over `body`, the body the later
|
||||||
/// round was given ([`crate::blend_corner::rebase`]), and whether each new edge (in
|
/// round was given ([`crate::blend_corner::rebase`]), and whether each new edge (in
|
||||||
/// `later`'s order) was removed whole by a larger, overflowing round.
|
/// `later`'s order) was removed whole by a larger, overflowing round. The rounds turn at side
|
||||||
fn reblend(body: &Body, what: &str, later: &crate::blend_corner::Later) -> Result<(Body, Vec<bool>)> {
|
/// edges as `turn` says.
|
||||||
|
fn reblend(body: &Body, what: &str, later: &crate::blend_corner::Later, turn: Turn) -> Result<(Body, Vec<bool>)> {
|
||||||
let base = &later.base;
|
let base = &later.base;
|
||||||
let mesh = base.mesh(&crate::MeshTolerance::chord((base.size() * 2e-3).max(1e-3)))?;
|
let mesh = base.mesh(&crate::MeshTolerance::chord((base.size() * 2e-3).max(1e-3)))?;
|
||||||
let mut indices = Vec::with_capacity(later.edges.len());
|
let mut indices = Vec::with_capacity(later.edges.len());
|
||||||
@@ -291,7 +304,7 @@ mod imp {
|
|||||||
}
|
}
|
||||||
sizes_ok(what, &radii)?;
|
sizes_ok(what, &radii)?;
|
||||||
let tol = pick_tolerance(base);
|
let tol = pick_tolerance(base);
|
||||||
let (out, dropped, _) = run_with(base, what, &indices, &[], |b| b.blend_dropping(Blend::Round, &points, &radii, tol))?;
|
let (out, dropped, _) = run_with(base, what, &indices, &[], |b| b.blend_dropping(Blend::Round, turn, &points, &radii, tol))?;
|
||||||
// The new edges are the last of `later.edges`.
|
// The new edges are the last of `later.edges`.
|
||||||
let dropped_new = dropped.get(dropped.len().saturating_sub(later.new_count())..).map(<[bool]>::to_vec).unwrap_or_default();
|
let dropped_new = dropped.get(dropped.len().saturating_sub(later.new_count())..).map(<[bool]>::to_vec).unwrap_or_default();
|
||||||
let history = out.history().and_then(|h| crate::blend_corner::rebase(h, later, body));
|
let history = out.history().and_then(|h| crate::blend_corner::rebase(h, later, body));
|
||||||
@@ -446,6 +459,11 @@ mod imp {
|
|||||||
run on tangentially into each other, or meet in a way no blend corner builds)"
|
run on tangentially into each other, or meet in a way no blend corner builds)"
|
||||||
)),
|
)),
|
||||||
SmError::NoRoom => KernelError::TooLarge(format!("the {what} is larger than the faces beside its edges allow")),
|
SmError::NoRoom => KernelError::TooLarge(format!("the {what} is larger than the faces beside its edges allow")),
|
||||||
|
// Not a size a smaller radius fixes: solidcore builds no swept turn there.
|
||||||
|
SmError::TurnRefused { why, at, .. } => {
|
||||||
|
let at = at.map(|[x, y, z]| format!(" at ({x:.3}, {y:.3}, {z:.3})")).unwrap_or_default();
|
||||||
|
KernelError::Invalid(format!("the {what}'s swept corner{at} is not built: {why}"))
|
||||||
|
}
|
||||||
SmError::TooLarge { size, kind, at, why } => {
|
SmError::TooLarge { size, kind, at, why } => {
|
||||||
let mut beside = match at {
|
let mut beside = match at {
|
||||||
Some([x, y, z]) => format!(", at the edge at ({x:.3}, {y:.3}, {z:.3})"),
|
Some([x, y, z]) => format!(", at the edge at ({x:.3}, {y:.3}, {z:.3})"),
|
||||||
@@ -594,7 +612,7 @@ mod tests {
|
|||||||
fn a_round_loses_its_corner_and_its_face_comes_from_the_edge() {
|
fn a_round_loses_its_corner_and_its_face_comes_from_the_edge() {
|
||||||
let b = cube();
|
let b = cube();
|
||||||
let at = Vec3::new(10.0, 20.0, 20.0);
|
let at = Vec3::new(10.0, 20.0, 20.0);
|
||||||
let r = imp::blend(&b, "fillet", Kind::Round, &[at], &[2.0]).unwrap();
|
let r = imp::blend(&b, "fillet", Kind::Round(FilletTurn::Mitre), &[at], &[2.0]).unwrap();
|
||||||
assert!(near(volume(&r), 8000.0 - QUARTER * 4.0 * 20.0), "{}", volume(&r));
|
assert!(near(volume(&r), 8000.0 - QUARTER * 4.0 * 20.0), "{}", volume(&r));
|
||||||
assert_eq!(r.face_count(), 7);
|
assert_eq!(r.face_count(), 7);
|
||||||
let h = r.history().expect("a history");
|
let h = r.history().expect("a history");
|
||||||
@@ -608,8 +626,8 @@ mod tests {
|
|||||||
// (20, 20, 10) is on the vertical edge only; (20, 20, 20) is a corner of three edges, which
|
// (20, 20, 10) is on the vertical edge only; (20, 20, 20) is a corner of three edges, which
|
||||||
// the body's own order resolves, never left to SMLib.
|
// the body's own order resolves, never left to SMLib.
|
||||||
let b = cube();
|
let b = cube();
|
||||||
assert!(imp::blend(&b, "fillet", Kind::Round, &[Vec3::new(20.0, 20.0, 20.0)], &[1.0]).is_ok());
|
assert!(imp::blend(&b, "fillet", Kind::Round(FilletTurn::Mitre), &[Vec3::new(20.0, 20.0, 20.0)], &[1.0]).is_ok());
|
||||||
assert!(imp::blend(&b, "fillet", Kind::Round, &[Vec3::new(60.0, 60.0, 60.0)], &[1.0]).is_err());
|
assert!(imp::blend(&b, "fillet", Kind::Round(FilletTurn::Mitre), &[Vec3::new(60.0, 60.0, 60.0)], &[1.0]).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A ball's rim on a block's back face, tangent to the back face's bottom edge at (2.5, 22.5, 0).
|
/// A ball's rim on a block's back face, tangent to the back face's bottom edge at (2.5, 22.5, 0).
|
||||||
@@ -677,7 +695,7 @@ mod tests {
|
|||||||
fn each_edge_takes_its_own_size() {
|
fn each_edge_takes_its_own_size() {
|
||||||
let b = cube();
|
let b = cube();
|
||||||
let picks = [Vec3::new(10.0, 20.0, 20.0), Vec3::new(10.0, 0.0, 20.0)];
|
let picks = [Vec3::new(10.0, 20.0, 20.0), Vec3::new(10.0, 0.0, 20.0)];
|
||||||
let r = imp::blend(&b, "fillet", Kind::Round, &picks, &[2.0, 3.0]).unwrap();
|
let r = imp::blend(&b, "fillet", Kind::Round(FilletTurn::Mitre), &picks, &[2.0, 3.0]).unwrap();
|
||||||
assert!(near(volume(&r), 8000.0 - QUARTER * 13.0 * 20.0), "{}", volume(&r));
|
assert!(near(volume(&r), 8000.0 - QUARTER * 13.0 * 20.0), "{}", volume(&r));
|
||||||
let c = imp::blend(&b, "chamfer", Kind::Chamfer, &picks, &[2.0, 3.0]).unwrap();
|
let c = imp::blend(&b, "chamfer", Kind::Chamfer, &picks, &[2.0, 3.0]).unwrap();
|
||||||
assert!(near(volume(&c), 8000.0 - 0.5 * 13.0 * 20.0), "{}", volume(&c));
|
assert!(near(volume(&c), 8000.0 - 0.5 * 13.0 * 20.0), "{}", volume(&c));
|
||||||
@@ -750,9 +768,9 @@ mod tests {
|
|||||||
// A radius law down to zero inside the edge would pinch the round to a point.
|
// A radius law down to zero inside the edge would pinch the round to a point.
|
||||||
let pinch = RadiusSpline { degree: 1, knots: vec![0.0, 0.0, 0.5, 1.0, 1.0], radii: vec![4.0, 0.0, 4.0] };
|
let pinch = RadiusSpline { degree: 1, knots: vec![0.0, 0.0, 0.5, 1.0, 1.0], radii: vec![4.0, 0.0, 4.0] };
|
||||||
assert!(imp::variable(&b, Vec3::new(10.0, 20.0, 20.0), Vec3::new(0.0, 20.0, 20.0), &pinch).is_err());
|
assert!(imp::variable(&b, Vec3::new(10.0, 20.0, 20.0), Vec3::new(0.0, 20.0, 20.0), &pinch).is_err());
|
||||||
assert!(imp::blend(&b, "fillet", Kind::Round, &[Vec3::new(10.0, 20.0, 20.0)], &[f64::NAN]).is_err());
|
assert!(imp::blend(&b, "fillet", Kind::Round(FilletTurn::Mitre), &[Vec3::new(10.0, 20.0, 20.0)], &[f64::NAN]).is_err());
|
||||||
assert!(imp::blend(&b, "fillet", Kind::Round, &[Vec3::new(f64::NAN, 20.0, 20.0)], &[1.0]).is_err());
|
assert!(imp::blend(&b, "fillet", Kind::Round(FilletTurn::Mitre), &[Vec3::new(f64::NAN, 20.0, 20.0)], &[1.0]).is_err());
|
||||||
assert!(imp::blend(&b, "fillet", Kind::Round, &[], &[]).is_err());
|
assert!(imp::blend(&b, "fillet", Kind::Round(FilletTurn::Mitre), &[], &[]).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A smooth edge (a round's seam with the face it runs into) has nothing to round: refused
|
/// A smooth edge (a round's seam with the face it runs into) has nothing to round: refused
|
||||||
@@ -764,30 +782,30 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn smooth_edges_and_unfinished_tangent_chains_are_refused_saying_why() {
|
fn smooth_edges_and_unfinished_tangent_chains_are_refused_saying_why() {
|
||||||
let b = cube();
|
let b = cube();
|
||||||
let rounded = fillet(&b, &[Vec3::new(10.0, 20.0, 20.0)], 3.0).unwrap();
|
let rounded = fillet(&b, &[Vec3::new(10.0, 20.0, 20.0)], 3.0, FilletTurn::Mitre).unwrap();
|
||||||
// the seam where the r3 round meets the top, 3 in from the old edge
|
// the seam where the r3 round meets the top, 3 in from the old edge
|
||||||
let seam = imp::blend(&rounded, "fillet", Kind::Round, &[Vec3::new(10.0, 17.0, 20.0)], &[1.0]).unwrap_err().to_string();
|
let seam = imp::blend(&rounded, "fillet", Kind::Round(FilletTurn::Mitre), &[Vec3::new(10.0, 17.0, 20.0)], &[1.0]).unwrap_err().to_string();
|
||||||
assert!(seam.contains("is smooth: its faces meet tangentially, so there is nothing to round"), "{seam}");
|
assert!(seam.contains("is smooth: its faces meet tangentially, so there is nothing to round"), "{seam}");
|
||||||
// the end face's top edge runs on tangentially into the round's end arc: its r1 round
|
// the end face's top edge runs on tangentially into the round's end arc: its r1 round
|
||||||
// goes on along y through the r3 round until that drops below it, at y = 17 + sqrt(5)
|
// goes on along y through the r3 round until that drops below it, at y = 17 + sqrt(5)
|
||||||
// (the rig case's closed form: 8000 - 20 (9 - 9 pi/4) - 17 (1 - pi/4) - 0.218236)
|
// (the rig case's closed form: 8000 - 20 (9 - 9 pi/4) - 17 (1 - pi/4) - 0.218236)
|
||||||
let top = Vec3::new(0.0, 8.0, 20.0);
|
let top = Vec3::new(0.0, 8.0, 20.0);
|
||||||
let alone = fillet(&rounded, &[top], 1.0).unwrap();
|
let alone = fillet(&rounded, &[top], 1.0, FilletTurn::Mitre).unwrap();
|
||||||
assert!((volume(&alone) - 7957.505202).abs() < 1e-3, "{}", volume(&alone));
|
assert!((volume(&alone) - 7957.505202).abs() < 1e-3, "{}", volume(&alone));
|
||||||
let chain = crate::tangent_chain(&rounded, &[top]).unwrap();
|
let chain = crate::tangent_chain(&rounded, &[top]).unwrap();
|
||||||
assert!(chain.len() > 1, "{chain:?}");
|
assert!(chain.len() > 1, "{chain:?}");
|
||||||
let whole = fillet(&rounded, &chain, 1.0).unwrap();
|
let whole = fillet(&rounded, &chain, 1.0, FilletTurn::Mitre).unwrap();
|
||||||
assert!(volume(&whole) < volume(&rounded));
|
assert!(volume(&whole) < volume(&rounded));
|
||||||
|
|
||||||
// r5 and r3 on two top edges at a corner, then r2 on the vertical edge under them: it
|
// r5 and r3 on two top edges at a corner, then r2 on the vertical edge under them: it
|
||||||
// runs on up the r5 round's end arc, and SMLib ends it there
|
// runs on up the r5 round's end arc, and SMLib ends it there
|
||||||
let b = box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(60.0, 40.0, 20.0)).unwrap();
|
let b = box_solid(Vec3::new(0.0, 0.0, 0.0), Vec3::new(60.0, 40.0, 20.0)).unwrap();
|
||||||
let b = fillet(&b, &[Vec3::new(30.0, 40.0, 20.0)], 5.0).unwrap();
|
let b = fillet(&b, &[Vec3::new(30.0, 40.0, 20.0)], 5.0, FilletTurn::Mitre).unwrap();
|
||||||
let b = fillet(&b, &[Vec3::new(60.0, 20.0, 20.0)], 3.0).unwrap();
|
let b = fillet(&b, &[Vec3::new(60.0, 20.0, 20.0)], 3.0, FilletTurn::Mitre).unwrap();
|
||||||
let vertical = Vec3::new(60.0, 40.0, 8.0);
|
let vertical = Vec3::new(60.0, 40.0, 8.0);
|
||||||
let chain = crate::tangent_chain(&b, &[vertical]).unwrap();
|
let chain = crate::tangent_chain(&b, &[vertical]).unwrap();
|
||||||
assert!(chain.len() > 1, "{chain:?}");
|
assert!(chain.len() > 1, "{chain:?}");
|
||||||
let run_on = fillet(&b, &[vertical], 2.0).unwrap();
|
let run_on = fillet(&b, &[vertical], 2.0, FilletTurn::Mitre).unwrap();
|
||||||
assert!(volume(&run_on) < volume(&b));
|
assert!(volume(&run_on) < volume(&b));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,20 +68,32 @@ pub(crate) fn frame_ok(plane: &Plane) -> bool {
|
|||||||
/// Extrude each region from `start` to `end` along the plane normal (offsets in mm, `end > start`).
|
/// Extrude each region from `start` to `end` along the plane normal (offsets in mm, `end > start`).
|
||||||
pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Vec<Body>> {
|
pub fn extrude(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Vec<Body>> {
|
||||||
let _span = solidcraft_profile::span("kernel", "extrude");
|
let _span = solidcraft_profile::span("kernel", "extrude");
|
||||||
extrude_with(plane, regions, start, end, false)
|
extrude_with(plane, regions, start, end, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`extrude`] with each run of profile lines that continue one another in a straight line (a side
|
/// [`extrude`] with each run of profile lines that continue one another in a straight line (a side
|
||||||
/// drawn in two pieces, a mirrored half meeting its mirror image) swept into one side face over
|
/// drawn in two pieces, a mirrored half meeting its mirror image) swept into one side face over
|
||||||
/// all its pieces, rather than coplanar faces with an edge between them (#1788). The side's
|
/// all its pieces, rather than coplanar faces with an edge between them (#1788), and each run of
|
||||||
|
/// free-form segments (conics, Béziers) that `one_curve` says are pieces of one curve (an
|
||||||
|
/// ellipse's four quarters, a spline's spans) swept as that one curve into one wall. The side's
|
||||||
/// history lists every piece under it. For the Extrude feature; other builders (sheet metal's
|
/// history lists every piece under it. For the Extrude feature; other builders (sheet metal's
|
||||||
/// flat pattern, tools) keep a face per segment.
|
/// flat pattern, tools) keep a face per segment.
|
||||||
pub fn extrude_whole_sides(plane: &Plane, regions: &[Region2], start: f64, end: f64) -> Result<Vec<Body>> {
|
pub fn extrude_whole_sides(plane: &Plane, regions: &[Region2], start: f64, end: f64, one_curve: &dyn Fn(&Seg2, &Seg2) -> bool) -> Result<Vec<Body>> {
|
||||||
let _span = solidcraft_profile::span("kernel", "extrude_whole_sides");
|
let _span = solidcraft_profile::span("kernel", "extrude_whole_sides");
|
||||||
extrude_with(plane, regions, start, end, true)
|
extrude_with(plane, regions, start, end, Some(one_curve))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn extrude_with(plane: &Plane, regions: &[Region2], start: f64, end: f64, whole_sides: bool) -> Result<Vec<Body>> {
|
/// The degree of a free-form segment (a conic or a Bézier), none for a line or an arc.
|
||||||
|
fn free_form_degree(s: &Seg2) -> Option<u8> {
|
||||||
|
match *s {
|
||||||
|
Seg2::Conic { .. } => Some(2),
|
||||||
|
Seg2::Cubic { .. } => Some(3),
|
||||||
|
Seg2::Bezier { n, .. } => Some(n),
|
||||||
|
Seg2::Line { .. } | Seg2::Arc { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extrude_with(plane: &Plane, regions: &[Region2], start: f64, end: f64, one_curve: Option<&dyn Fn(&Seg2, &Seg2) -> bool>) -> Result<Vec<Body>> {
|
||||||
if !frame_ok(plane) {
|
if !frame_ok(plane) {
|
||||||
return Err(KernelError::Invalid("extrude frame is not finite and orthonormal".into()));
|
return Err(KernelError::Invalid("extrude frame is not finite and orthonormal".into()));
|
||||||
}
|
}
|
||||||
@@ -98,8 +110,26 @@ fn extrude_with(plane: &Plane, regions: &[Region2], start: f64, end: f64, whole_
|
|||||||
.map(|r| {
|
.map(|r| {
|
||||||
region_ok(r)?;
|
region_ok(r)?;
|
||||||
let pieces = oriented(r)?;
|
let pieces = oriented(r)?;
|
||||||
let numbered = if whole_sides { pieces.map_loops(|l| l.merged_lines(continues_straight)) } else { pieces.clone() };
|
let Some(one_curve) = one_curve else {
|
||||||
crate::smlib_sweeps::extrude(plane, &crate::smlib_sweeps::region_pieces(&numbered, &pieces), Some(r), lo, hi - lo, 1.0, None)
|
return crate::smlib_sweeps::extrude(plane, &crate::smlib_sweeps::region_pieces(&pieces, &pieces), Some(r), lo, hi - lo, 1.0, None);
|
||||||
|
};
|
||||||
|
// Pieces of one curve: free-form segments of one degree that the caller says are one
|
||||||
|
// curve. Each loop starts where such a run starts, so no run wraps round its end.
|
||||||
|
let joins = |s: &Seg2, t: &Seg2| free_form_degree(s).is_some_and(|d| free_form_degree(t) == Some(d)) && one_curve(s, t);
|
||||||
|
let numbered = pieces.map_loops(|l| l.merged_lines(continues_straight).starting_a_run(joins));
|
||||||
|
let mut swept = crate::smlib_sweeps::region_pieces(&numbered, &pieces);
|
||||||
|
let mut k = 0;
|
||||||
|
for l in numbered.loops() {
|
||||||
|
for i in 1..l.lp.segs.len() {
|
||||||
|
if let (Some(s), Some(t)) = (l.lp.segs.get(i - 1), l.lp.segs.get(i))
|
||||||
|
&& let Some(x) = swept.get_mut(k + i)
|
||||||
|
{
|
||||||
|
x.joins_previous = joins(s, t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
k += l.lp.segs.len();
|
||||||
|
}
|
||||||
|
crate::smlib_sweeps::extrude(plane, &swept, Some(r), lo, hi - lo, 1.0, None)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ fn collinear_profile_lines_extrude_into_one_side_face() {
|
|||||||
let region = Region2 { outer: Loop2::polygon(&pts), holes: vec![] };
|
let region = Region2 { outer: Loop2::polygon(&pts), holes: vec![] };
|
||||||
// Plain `extrude` keeps a face per segment (sheet metal's flat pattern relies on it).
|
// Plain `extrude` keeps a face per segment (sheet metal's flat pattern relies on it).
|
||||||
assert_eq!(crate::extrude(&Plane::XY, std::slice::from_ref(®ion), 0.0, 5.0).unwrap()[0].face_count(), 7);
|
assert_eq!(crate::extrude(&Plane::XY, std::slice::from_ref(®ion), 0.0, 5.0).unwrap()[0].face_count(), 7);
|
||||||
let body = crate::extrude_whole_sides(&Plane::XY, &[region], 0.0, 5.0).unwrap().pop().unwrap();
|
let body = crate::extrude_whole_sides(&Plane::XY, &[region], 0.0, 5.0, &|_, _| false).unwrap().pop().unwrap();
|
||||||
assert_eq!(body.face_count(), 6, "a box has six faces");
|
assert_eq!(body.face_count(), 6, "a box has six faces");
|
||||||
assert!((crate::measure(&body).unwrap().volume - 1000.0).abs() < 1e-9);
|
assert!((crate::measure(&body).unwrap().volume - 1000.0).abs() < 1e-9);
|
||||||
let h = body.history().unwrap();
|
let h = body.history().unwrap();
|
||||||
@@ -276,10 +276,75 @@ fn collinear_profile_lines_extrude_into_one_side_face() {
|
|||||||
);
|
);
|
||||||
// A corner is not a straight run: an L-shaped step keeps its faces.
|
// A corner is not a straight run: an L-shaped step keeps its faces.
|
||||||
let step = [(0.0, 0.0), (10.0, 0.0), (10.0, 5.0), (20.0, 5.0), (20.0, 10.0), (0.0, 10.0)].map(|(x, y)| Vec2::new(x, y));
|
let step = [(0.0, 0.0), (10.0, 0.0), (10.0, 5.0), (20.0, 5.0), (20.0, 10.0), (0.0, 10.0)].map(|(x, y)| Vec2::new(x, y));
|
||||||
let body = crate::extrude_whole_sides(&Plane::XY, &[Region2 { outer: Loop2::polygon(&step), holes: vec![] }], 0.0, 5.0).unwrap().pop().unwrap();
|
let body = crate::extrude_whole_sides(&Plane::XY, &[Region2 { outer: Loop2::polygon(&step), holes: vec![] }], 0.0, 5.0, &|_, _| false)
|
||||||
|
.unwrap()
|
||||||
|
.pop()
|
||||||
|
.unwrap();
|
||||||
assert_eq!(body.face_count(), 8);
|
assert_eq!(body.face_count(), 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The pieces of one ellipse (four conic quarters) or one spline (its cubic spans) that the
|
||||||
|
/// caller calls one curve sweep into one wall generated by all of them, the same solid as a face
|
||||||
|
/// per piece: the ellipse's volume is π a b h. Pieces not called one curve keep a face each.
|
||||||
|
#[test]
|
||||||
|
fn pieces_of_one_curve_extrude_into_one_wall() {
|
||||||
|
use solidcore_record::{Loop2, Plane, Region2, Seg2, Vec2};
|
||||||
|
let v = Vec2::new;
|
||||||
|
let (c, u, w) = (v(1.0, 2.0), v(10.0, 0.0), v(0.0, 5.0));
|
||||||
|
let corners = [c + u, c + w, c - u, c - w];
|
||||||
|
let ellipse: Vec<Seg2> = (0..4)
|
||||||
|
.map(|i| {
|
||||||
|
let (a, b) = (corners[i], corners[(i + 1) % 4]);
|
||||||
|
Seg2::Conic { a, apex: a + b - c, b, w: std::f64::consts::FRAC_1_SQRT_2 }
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// A cubic spline's three spans (C2 at its joints, on unequal knot spans), closed by lines.
|
||||||
|
let spline = vec![
|
||||||
|
Seg2::Cubic {
|
||||||
|
p0: v(0.0, 0.0),
|
||||||
|
p1: v(3.478154619431441, -1.9606112245268097),
|
||||||
|
p2: v(6.956309238862882, -3.921222449053619),
|
||||||
|
p3: v(10.0, -3.0),
|
||||||
|
},
|
||||||
|
Seg2::Cubic {
|
||||||
|
p0: v(10.0, -3.0),
|
||||||
|
p1: v(13.55861013478923, -1.9229288384169465),
|
||||||
|
p2: v(16.52332013301099, 3.0935296384274267),
|
||||||
|
p3: v(20.0, 4.0),
|
||||||
|
},
|
||||||
|
Seg2::Cubic {
|
||||||
|
p0: v(20.0, 4.0),
|
||||||
|
p1: v(23.067612954017893, 4.799814860722723),
|
||||||
|
p2: v(26.533806477008945, 2.3999074303613614),
|
||||||
|
p3: v(30.0, 0.0),
|
||||||
|
},
|
||||||
|
Seg2::Line { a: v(30.0, 0.0), b: v(30.0, 20.0) },
|
||||||
|
Seg2::Line { a: v(30.0, 20.0), b: v(0.0, 20.0) },
|
||||||
|
Seg2::Line { a: v(0.0, 20.0), b: v(0.0, 0.0) },
|
||||||
|
];
|
||||||
|
for (segs, walls, pieces) in [(ellipse, 1, 4), (spline, 4, 3)] {
|
||||||
|
let region = Region2 { outer: Loop2 { segs }, holes: vec![] };
|
||||||
|
let apart = crate::extrude_whole_sides(&Plane::XY, std::slice::from_ref(®ion), 0.0, 7.0, &|_, _| false).unwrap().remove(0);
|
||||||
|
let body = crate::extrude_whole_sides(&Plane::XY, std::slice::from_ref(®ion), 0.0, 7.0, &|_, _| true).unwrap().remove(0);
|
||||||
|
assert_eq!(apart.face_count(), 2 + walls - 1 + pieces, "a face per piece when they are not one curve");
|
||||||
|
assert_eq!(body.face_count(), 2 + walls, "one wall over the curve's pieces");
|
||||||
|
let (joined, each) = (crate::measure(&body).unwrap().volume, crate::measure(&apart).unwrap().volume);
|
||||||
|
assert!((joined - each).abs() < 1e-9 * each, "{joined} vs {each}");
|
||||||
|
let h = body.history().unwrap();
|
||||||
|
h.check(&body, &[]).unwrap();
|
||||||
|
let curved: Vec<Vec<FaceOrigin>> = h.faces.iter().map(|f| f.origins.clone()).filter(|o| o.len() > 1).collect();
|
||||||
|
assert_eq!(curved.len(), 1, "{:?}", h.faces);
|
||||||
|
let mut segments: Vec<usize> =
|
||||||
|
curved[0].iter().filter_map(|o| if let FaceOrigin::Profile { segment } = o { Some(*segment) } else { None }).collect();
|
||||||
|
segments.sort();
|
||||||
|
assert_eq!(segments, (0..pieces).collect::<Vec<_>>(), "the wall is generated by every piece of the curve");
|
||||||
|
if walls == 1 {
|
||||||
|
let exact = std::f64::consts::PI * 10.0 * 5.0 * 7.0;
|
||||||
|
assert!((joined - exact).abs() < 1e-6 * exact, "{joined} vs π a b h = {exact}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A tapered extrude joins a side drawn in collinear pieces as the straight extrude does
|
/// A tapered extrude joins a side drawn in collinear pieces as the straight extrude does
|
||||||
/// (#1788): one sloped face generated by both pieces, and the same solid as a side in one piece.
|
/// (#1788): one sloped face generated by both pieces, and the same solid as a side in one piece.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ fn chamfers_crossing_each_other_are_refused() {
|
|||||||
too_large(crate::chamfer(&rib, &edges, 3.0));
|
too_large(crate::chamfer(&rib, &edges, 3.0));
|
||||||
too_large(crate::fillet(&rib, &edges, 3.0));
|
too_large(crate::fillet(&rib, &edges, 3.0));
|
||||||
assert_eq!(crate::chamfer_max(&rib, &edges, 3.0).unwrap().map(|x| x.0), Some(2.0));
|
assert_eq!(crate::chamfer_max(&rib, &edges, 3.0).unwrap().map(|x| x.0), Some(2.0));
|
||||||
assert_eq!(crate::fillet_max(&rib, &edges, 3.0).unwrap().map(|x| x.0), Some(2.0));
|
assert_eq!(crate::fillet_max(&rib, &edges, 3.0, crate::FilletTurn::Mitre).unwrap().map(|x| x.0), Some(2.0));
|
||||||
// Below half the width both build.
|
// Below half the width both build.
|
||||||
let b = crate::chamfer(&rib, &edges, 1.5).unwrap();
|
let b = crate::chamfer(&rib, &edges, 1.5).unwrap();
|
||||||
assert!((b.volume_at(0.01).unwrap() - (3200.0 - 2.0 * 0.5 * 1.5 * 1.5 * 40.0)).abs() < 1e-6);
|
assert!((b.volume_at(0.01).unwrap() - (3200.0 - 2.0 * 0.5 * 1.5 * 1.5 * 40.0)).abs() < 1e-6);
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ mod validity;
|
|||||||
pub mod watch;
|
pub mod watch;
|
||||||
|
|
||||||
pub use blend::{
|
pub use blend::{
|
||||||
ChamferSide, SETBACK_AT_LEAST, blend_edges, chamfer, chamfer_max, chamfer_sides, fillet, fillet_each, fillet_g2, fillet_g2_corners,
|
ChamferSide, FilletTurn, SETBACK_AT_LEAST, blend_edges, chamfer, chamfer_max, chamfer_sides, fillet, fillet_each, fillet_g2, fillet_g2_corners,
|
||||||
fillet_g2_with_g2_corners, fillet_least_setback, fillet_max, fillet_max_each, fillet_removing, fillet_setback, largest_fit_note,
|
fillet_g2_with_g2_corners, fillet_least_setback, fillet_max, fillet_max_each, fillet_removing, fillet_setback, largest_fit_note,
|
||||||
names_largest_fit, tangent_chain, with_fit_note,
|
names_largest_fit, tangent_chain, with_fit_note,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -152,6 +152,19 @@ impl NumberedLoop {
|
|||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The same loop started at a segment `joins` does not join to the one before it, so no run of
|
||||||
|
/// joined segments wraps round the loop's end (as it is when every segment joins the next).
|
||||||
|
pub(crate) fn starting_a_run(&self, joins: impl Fn(&Seg2, &Seg2) -> bool) -> NumberedLoop {
|
||||||
|
let segs = &self.lp.segs;
|
||||||
|
let n = segs.len();
|
||||||
|
let joined = |i: usize| matches!((segs.get((i + n - 1) % n), segs.get(i)), (Some(s), Some(t)) if joins(s, t));
|
||||||
|
let mut out = self.clone();
|
||||||
|
if let Some(k) = (0..n).find(|&i| !joined(i)) {
|
||||||
|
out.rotate_left(k);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Split segment `k` in half, both halves over what it ran over.
|
/// Split segment `k` in half, both halves over what it ran over.
|
||||||
fn split_half(&mut self, k: usize) {
|
fn split_half(&mut self, k: usize) {
|
||||||
if k >= self.lp.segs.len() || k >= self.from.len() {
|
if k >= self.lp.segs.len() || k >= self.from.len() {
|
||||||
|
|||||||
@@ -172,6 +172,119 @@ pub(super) fn same_support(a: &Plane, b: &Plane, at: Resolution) -> bool {
|
|||||||
&& (a.origin - b.origin).dot(nb).abs() <= at.linear
|
&& (a.origin - b.origin).dot(nb).abs() <= at.linear
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A source a face on its plane can take: the plane it names (for a shell's inner role, the
|
||||||
|
/// source's plane moved in and turned round), the source, and whether it is that inner role.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
struct Candidate<'a> {
|
||||||
|
plane: Plane,
|
||||||
|
source: &'a FaceSource,
|
||||||
|
inner: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a mapping's inputs say of one plane of its result.
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
enum Support<'a> {
|
||||||
|
/// A face without a role, or a competing tool face, lies on the plane.
|
||||||
|
Blocked,
|
||||||
|
/// Every candidate on the plane is one source, plane and role: the pieces of one logical face.
|
||||||
|
Source(Candidate<'a>),
|
||||||
|
/// No candidate lies on the plane, or candidates of distinct identities do.
|
||||||
|
Unnamed,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Planes partitioned into supports, in their order: a plane starts a group unless it is one
|
||||||
|
/// support (`same_support`) with the first plane of an earlier group, which it then joins. Returns
|
||||||
|
/// each plane's group and each group's first plane; `looked` counts the planes looked at.
|
||||||
|
fn group_supports(planes: &[Plane], at: Resolution, looked: &mut usize) -> (Vec<usize>, Vec<usize>) {
|
||||||
|
let index = PlaneIndex::new(planes.iter(), at);
|
||||||
|
let mut group: Vec<Option<usize>> = vec![None; planes.len()];
|
||||||
|
let mut first = Vec::new();
|
||||||
|
for (i, plane) in planes.iter().enumerate() {
|
||||||
|
if group.get(i).is_some_and(Option::is_some) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let g = first.len();
|
||||||
|
first.push(i);
|
||||||
|
let near = index.near(plane);
|
||||||
|
*looked += near.len();
|
||||||
|
for j in near {
|
||||||
|
if let Some(slot @ None) = group.get_mut(j)
|
||||||
|
&& (j == i || planes.get(j).is_some_and(|other| same_support(plane, other, at)))
|
||||||
|
{
|
||||||
|
*slot = Some(g);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(group.into_iter().map(|g| g.unwrap_or(usize::MAX)).collect(), first)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The candidates and blocking planes of a mapping grouped by support once, so each result face
|
||||||
|
/// looks up its plane's few groups instead of every candidate on that plane: with many coplanar
|
||||||
|
/// faces the work grows with the faces plus the candidates, not with their product.
|
||||||
|
struct SupportDecisions<'a> {
|
||||||
|
/// Each candidate group's first plane, and its decision: its first candidate, or none when
|
||||||
|
/// the group holds candidates of distinct identities.
|
||||||
|
groups: Vec<(Plane, Option<Candidate<'a>>)>,
|
||||||
|
group_index: PlaneIndex,
|
||||||
|
/// The first plane of each group of the faces without a role and the competing tool faces.
|
||||||
|
blocking: Vec<Plane>,
|
||||||
|
blocking_index: PlaneIndex,
|
||||||
|
at: Resolution,
|
||||||
|
/// The planes looked at, building the groups and deciding faces.
|
||||||
|
looked: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> SupportDecisions<'a> {
|
||||||
|
/// `candidates` in the order that decides between them.
|
||||||
|
fn new(candidates: &[Candidate<'a>], unknown: &[Plane], competing: &[Plane], at: Resolution) -> Self {
|
||||||
|
let mut looked = 0;
|
||||||
|
let planes: Vec<Plane> = candidates.iter().map(|c| c.plane).collect();
|
||||||
|
let (group, first) = group_supports(&planes, at, &mut looked);
|
||||||
|
let mut groups: Vec<(Plane, Option<Candidate<'a>>)> = first.iter().filter_map(|&i| candidates.get(i)).map(|c| (c.plane, Some(*c))).collect();
|
||||||
|
for (candidate, g) in candidates.iter().zip(&group) {
|
||||||
|
if let Some((_, decision)) = groups.get_mut(*g)
|
||||||
|
&& decision.is_some_and(|d| d != *candidate)
|
||||||
|
{
|
||||||
|
*decision = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let group_index = PlaneIndex::new(groups.iter().map(|g| &g.0), at);
|
||||||
|
let all_blocking: Vec<Plane> = unknown.iter().chain(competing).copied().collect();
|
||||||
|
let (_, first) = group_supports(&all_blocking, at, &mut looked);
|
||||||
|
let blocking: Vec<Plane> = first.iter().filter_map(|&i| all_blocking.get(i).copied()).collect();
|
||||||
|
let blocking_index = PlaneIndex::new(blocking.iter(), at);
|
||||||
|
SupportDecisions { groups, group_index, blocking, blocking_index, at, looked }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the inputs say of a face on `actual`.
|
||||||
|
fn at(&mut self, actual: &Plane) -> Support<'a> {
|
||||||
|
let at = self.at;
|
||||||
|
let near = self.blocking_index.near(actual);
|
||||||
|
self.looked += near.len();
|
||||||
|
if near.into_iter().any(|i| self.blocking.get(i).is_some_and(|plane| same_support(plane, actual, at))) {
|
||||||
|
return Support::Blocked;
|
||||||
|
}
|
||||||
|
let mut near = self.group_index.near(actual);
|
||||||
|
self.looked += near.len();
|
||||||
|
// Groups are numbered in the candidates' order, so the first matching group's first
|
||||||
|
// candidate is the first candidate on the plane.
|
||||||
|
near.sort_unstable();
|
||||||
|
let mut chosen: Option<Candidate<'a>> = None;
|
||||||
|
for (plane, decision) in near.into_iter().filter_map(|g| self.groups.get(g)) {
|
||||||
|
if !same_support(plane, actual, at) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match (chosen, decision) {
|
||||||
|
(_, None) => return Support::Unnamed,
|
||||||
|
(None, Some(d)) => chosen = Some(*d),
|
||||||
|
(Some(c), Some(d)) if c != *d => return Support::Unnamed,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chosen.map_or(Support::Unnamed, Support::Source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn base_plane(source: &FaceSource, actual: &Plane) -> Option<Plane> {
|
fn base_plane(source: &FaceSource, actual: &Plane) -> Option<Plane> {
|
||||||
match source {
|
match source {
|
||||||
FaceSource::CertifiedExtrudeSide { plane: Some(p), .. }
|
FaceSource::CertifiedExtrudeSide { plane: Some(p), .. }
|
||||||
@@ -339,98 +452,40 @@ impl Body {
|
|||||||
unknown_supports.push(actual);
|
unknown_supports.push(actual);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(cost) = descriptor_cost(source) else {
|
if descriptor_cost(source).is_none() {
|
||||||
unknown_supports.push(actual);
|
unknown_supports.push(actual);
|
||||||
continue;
|
continue;
|
||||||
};
|
}
|
||||||
candidates.push((plane, source, false, cost));
|
candidates.push(Candidate { plane, source, inner: false });
|
||||||
if let Some(t) = shell {
|
if let Some(t) = shell {
|
||||||
let inner = Plane::new(plane.origin - plane.normal() * t, plane.x, -plane.y)
|
let inner = Plane::new(plane.origin - plane.normal() * t, plane.x, -plane.y)
|
||||||
.ok_or_else(|| KernelError::Invalid("invalid shell inner source plane".into()))?;
|
.ok_or_else(|| KernelError::Invalid("invalid shell inner source plane".into()))?;
|
||||||
candidates.push((inner, source, true, cost));
|
candidates.push(Candidate { plane: inner, source, inner: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The candidates are taken in the order of their normal's x: where several match one
|
// The candidates are taken in the order of their normal's x: where several match one
|
||||||
// face, the first decides which the rest are compared with.
|
// face, the first decides which the rest are compared with.
|
||||||
candidates.sort_by(|a, b| a.0.normal().x.total_cmp(&b.0.normal().x));
|
candidates.sort_by(|a, b| a.plane.normal().x.total_cmp(&b.plane.normal().x));
|
||||||
let unknown_index = PlaneIndex::new(unknown_supports.iter(), at);
|
let mut decide = SupportDecisions::new(&candidates, &unknown_supports, &competing, at);
|
||||||
let competing_index = PlaneIndex::new(competing.iter(), at);
|
|
||||||
let candidate_index = PlaneIndex::new(candidates.iter().map(|c| &c.0), at);
|
|
||||||
let mut metadata = 2_000_000usize;
|
|
||||||
let mut identity_bytes = 16_000_000usize;
|
|
||||||
let mut result = Vec::new();
|
let mut result = Vec::new();
|
||||||
for (index, actual) in actual_planes(&self)?.into_iter().enumerate() {
|
for (index, actual) in actual_planes(&self)?.into_iter().enumerate() {
|
||||||
let mut found = None;
|
// A face no candidate names keeps what it had, where an operation could have left
|
||||||
if let Some(actual) = actual {
|
// it a source no role describes.
|
||||||
let on = |planes: &[Plane], index: &PlaneIndex| {
|
let existing = || {
|
||||||
index.near(&actual).into_iter().any(|i| planes.get(i).is_some_and(|plane| same_support(plane, &actual, at)))
|
self.face_sources()
|
||||||
};
|
|
||||||
let blocked = on(&unknown_supports, &unknown_index) || on(&competing, &competing_index);
|
|
||||||
if blocked {
|
|
||||||
let existing = self
|
|
||||||
.face_sources()
|
|
||||||
.and_then(|sources| sources.get(index))
|
|
||||||
.and_then(|s| s.as_ref())
|
|
||||||
.filter(|s| (boolean || self.face_was_coalesced(index)) && !boolean_source(s, constructed));
|
|
||||||
let keep = existing.and_then(|s| {
|
|
||||||
descriptor_cost(s).and_then(|cost| {
|
|
||||||
metadata.checked_sub(cost).map(|remaining| {
|
|
||||||
metadata = remaining;
|
|
||||||
s.clone()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
});
|
|
||||||
result.push(keep);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let mut chosen: Option<(Plane, &FaceSource, bool, usize)> = None;
|
|
||||||
let mut near = candidate_index.near(&actual);
|
|
||||||
near.sort_unstable();
|
|
||||||
for (plane, source, inner, cost) in near.into_iter().filter_map(|i| candidates.get(i)) {
|
|
||||||
if same_support(plane, &actual, at) {
|
|
||||||
if let Some((previous_plane, previous_source, previous_inner, _)) = chosen {
|
|
||||||
let Some(remaining) = identity_bytes.checked_sub(*cost) else {
|
|
||||||
chosen = None;
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
identity_bytes = remaining;
|
|
||||||
// Several current pieces of one exact source are one
|
|
||||||
// logical face; distinct source identities remain ambiguous.
|
|
||||||
if previous_plane != *plane || previous_source != *source || previous_inner != *inner {
|
|
||||||
chosen = None;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
chosen = Some((*plane, *source, *inner, *cost));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some((plane, source, inner, cost)) = chosen
|
|
||||||
&& let Some(remaining) = metadata.checked_sub(cost)
|
|
||||||
{
|
|
||||||
metadata = remaining;
|
|
||||||
found = Some(if shell.is_some() {
|
|
||||||
FaceSource::ShellPlanar { producer: None, source: Box::new(source.clone()), inner, plane }
|
|
||||||
} else {
|
|
||||||
source.clone()
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if found.is_none() && (boolean || self.face_was_coalesced(index)) {
|
|
||||||
found = self
|
|
||||||
.face_sources()
|
|
||||||
.and_then(|sources| sources.get(index))
|
.and_then(|sources| sources.get(index))
|
||||||
.and_then(|s| s.as_ref())
|
.and_then(|s| s.as_ref())
|
||||||
.filter(|s| !boolean_source(s, constructed))
|
.filter(|s| (boolean || self.face_was_coalesced(index)) && !boolean_source(s, constructed) && descriptor_cost(s).is_some())
|
||||||
.and_then(|s| {
|
.cloned()
|
||||||
descriptor_cost(s).and_then(|cost| {
|
};
|
||||||
metadata.checked_sub(cost).map(|remaining| {
|
let found = match actual.map(|actual| decide.at(&actual)) {
|
||||||
metadata = remaining;
|
Some(Support::Source(Candidate { plane, source, inner })) => Some(if shell.is_some() {
|
||||||
s.clone()
|
FaceSource::ShellPlanar { producer: None, source: Box::new(source.clone()), inner, plane }
|
||||||
})
|
} else {
|
||||||
})
|
source.clone()
|
||||||
});
|
}),
|
||||||
}
|
Some(Support::Blocked | Support::Unnamed) | None => existing(),
|
||||||
|
};
|
||||||
result.push(found);
|
result.push(found);
|
||||||
}
|
}
|
||||||
Ok(result)
|
Ok(result)
|
||||||
@@ -574,6 +629,90 @@ mod tests {
|
|||||||
assert!(matches > 2 * planes.len(), "the planes meet each other, not only themselves: {matches}");
|
assert!(matches > 2 * planes.len(), "the planes meet each other, not only themselves: {matches}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Many faces on one plane are decided with work that grows with the faces plus the
|
||||||
|
/// candidates: each face looks at its plane's one group, never at every candidate on it.
|
||||||
|
#[test]
|
||||||
|
fn coplanar_faces_are_decided_by_group_in_linear_work() {
|
||||||
|
let body = box_solid(Vec3::ZERO, Vec3::new(1000., 1000., 10.)).unwrap();
|
||||||
|
let at = Resolution::of(&body);
|
||||||
|
let top = Plane::new(Vec3::new(0., 0., 10.), Vec3::X, Vec3::Y).unwrap();
|
||||||
|
let one = FaceSource::Box { producer: Some(1), axis: 2, high: true, plane: top };
|
||||||
|
let other = FaceSource::Box { producer: Some(2), axis: 2, high: true, plane: top };
|
||||||
|
for n in [100usize, 1000] {
|
||||||
|
// `n` pieces of one cap and `n` faces of the result on its plane, each a little moved
|
||||||
|
// along the plane (as a split face's pieces are).
|
||||||
|
let pieces: Vec<Plane> = (0..n).map(|k| Plane::new(Vec3::new(k as f64, 0., 10.), Vec3::X, Vec3::Y).unwrap()).collect();
|
||||||
|
let candidates: Vec<Candidate> = (0..n).map(|_| Candidate { plane: top, source: &one, inner: false }).collect();
|
||||||
|
let mut decide = SupportDecisions::new(&candidates, &[], &[], at);
|
||||||
|
for piece in &pieces {
|
||||||
|
assert_eq!(decide.at(piece), Support::Source(Candidate { plane: top, source: &one, inner: false }));
|
||||||
|
}
|
||||||
|
// One look per candidate to group them, one per face to find its group: a scan of
|
||||||
|
// every candidate per face would look n * n times.
|
||||||
|
assert_eq!(decide.looked, 2 * n);
|
||||||
|
// A second identity among the candidates leaves the plane unnamed; a face without a
|
||||||
|
// role on it blocks it.
|
||||||
|
let mut mixed = candidates.clone();
|
||||||
|
mixed[n / 2] = Candidate { plane: top, source: &other, inner: false };
|
||||||
|
assert_eq!(SupportDecisions::new(&mixed, &[], &[], at).at(&pieces[0]), Support::Unnamed);
|
||||||
|
let unknown = vec![top; n];
|
||||||
|
let mut blocked = SupportDecisions::new(&candidates, &unknown, &[], at);
|
||||||
|
assert!(pieces.iter().all(|piece| blocked.at(piece) == Support::Blocked));
|
||||||
|
assert_eq!(blocked.looked, 3 * n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A plate with 300 holes whose top a comb of grooves cuts into 62 strips: every strip keeps
|
||||||
|
/// the top cap's source, every bottom face the bottom cap's, however many there are.
|
||||||
|
#[test]
|
||||||
|
fn many_coplanar_pieces_of_a_holed_plate_all_keep_their_cap_source() {
|
||||||
|
use solidcore_record::{Loop2, Region2, Vec2};
|
||||||
|
let holes: Vec<Loop2> =
|
||||||
|
(0..60).flat_map(|k| (0..5).map(move |r| Loop2::circle(Vec2::new(10. + 10. * k as f64, 5. + 10. * r as f64), 1.))).collect();
|
||||||
|
let outer = Loop2::polygon(&[Vec2::new(0., 0.), Vec2::new(610., 0.), Vec2::new(610., 50.), Vec2::new(0., 50.)]);
|
||||||
|
let region = Region2 { outer, holes };
|
||||||
|
let raw = crate::extrude(&Plane::XY, std::slice::from_ref(®ion), 0., 10.).unwrap().remove(0);
|
||||||
|
let hole_ids: Vec<Vec<String>> = (0..300).map(|k| vec![format!("c{k}")]).collect();
|
||||||
|
let signatures: Vec<_> = raw
|
||||||
|
.face_sources()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|s| matches!(s, Some(FaceSource::ExtrudeCap { .. })).then(|| (vec!["l1".to_string()], hole_ids.clone())))
|
||||||
|
.collect();
|
||||||
|
let plate = raw.with_extrude_cap_certificates(5, 3, &signatures).unwrap();
|
||||||
|
// A comb: 61 teeth 2 wide across the plate between the columns of holes, 2 deep.
|
||||||
|
let mut comb = vec![Vec2::new(-5., 55.), Vec2::new(-5., 60.), Vec2::new(615., 60.), Vec2::new(615., 55.)];
|
||||||
|
for k in (0..=60).rev() {
|
||||||
|
let x = 4. + 10. * k as f64;
|
||||||
|
comb.extend([Vec2::new(x + 2., 55.), Vec2::new(x + 2., -5.), Vec2::new(x, -5.), Vec2::new(x, 55.)]);
|
||||||
|
}
|
||||||
|
let comb = Region2 { outer: Loop2::polygon(&comb), holes: vec![] };
|
||||||
|
let tool = crate::extrude(&Plane::XY, std::slice::from_ref(&comb), 8., 12.).unwrap().remove(0);
|
||||||
|
let cut = crate::boolean(&plate, &tool, crate::BoolOp::Cut).unwrap().unwrap();
|
||||||
|
let sources = cut.face_sources().unwrap();
|
||||||
|
let (mut tops, mut bottoms) = (0, 0);
|
||||||
|
for (i, plane) in actual_planes(&cut).unwrap().into_iter().enumerate() {
|
||||||
|
let Some(plane) = plane else { continue };
|
||||||
|
let high = if plane.normal().dot(Vec3::Z) > 0.999999 && (plane.origin.z - 10.).abs() < 1e-9 {
|
||||||
|
true
|
||||||
|
} else if plane.normal().dot(Vec3::Z) < -0.999999 && plane.origin.z.abs() < 1e-9 {
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(FaceSource::CertifiedExtrudeCap { producer: 5, high: h, holes, .. }) = &sources[i] else {
|
||||||
|
panic!("face {i} on the {} cap lost its source: {:?}", if high { "top" } else { "bottom" }, sources[i]);
|
||||||
|
};
|
||||||
|
assert_eq!((*h, holes.len()), (high, 300));
|
||||||
|
if high {
|
||||||
|
tops += 1;
|
||||||
|
} else {
|
||||||
|
bottoms += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert_eq!((tops, bottoms), (62, 1));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rounded_shell_planar_roles_are_exact_distinct_and_transformable() {
|
fn rounded_shell_planar_roles_are_exact_distinct_and_transformable() {
|
||||||
for (width, thickness) in [(80., 2.), (100., 3.)] {
|
for (width, thickness) in [(80., 2.), (100., 3.)] {
|
||||||
|
|||||||
@@ -31,11 +31,17 @@ pub(crate) struct Piece {
|
|||||||
/// The segment as drawn that names it: itself, or for collinear lines joined into one, the
|
/// The segment as drawn that names it: itself, or for collinear lines joined into one, the
|
||||||
/// first of them.
|
/// first of them.
|
||||||
pub drawn: Seg2,
|
pub drawn: Seg2,
|
||||||
|
/// It carries on the piece before it as one curve (pieces of one ellipse or spline): SMLib
|
||||||
|
/// gets the run as one B-spline and sweeps one wall over it.
|
||||||
|
pub joins_previous: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The segments of `loops` as pieces, loop after loop.
|
/// The segments of `loops` as pieces, loop after loop.
|
||||||
pub(crate) fn pieces<'a>(loops: impl IntoIterator<Item = &'a NumberedLoop>) -> Vec<Piece> {
|
pub(crate) fn pieces<'a>(loops: impl IntoIterator<Item = &'a NumberedLoop>) -> Vec<Piece> {
|
||||||
loops.into_iter().flat_map(|l| l.lp.segs.iter().enumerate().map(|(i, s)| Piece { seg: *s, numbers: l.numbers(i).to_vec(), drawn: *s })).collect()
|
loops
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|l| l.lp.segs.iter().enumerate().map(|(i, s)| Piece { seg: *s, numbers: l.numbers(i).to_vec(), drawn: *s, joins_previous: false }))
|
||||||
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The pieces of `joined` (`drawn` with collinear lines joined): outer loop, then holes, each
|
/// The pieces of `joined` (`drawn` with collinear lines joined): outer loop, then holes, each
|
||||||
@@ -145,8 +151,70 @@ mod imp {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The pieces as SMLib's curves: one each, but one B-spline for each run of pieces that join
|
||||||
|
/// the one before them ([`Piece::joins_previous`]).
|
||||||
fn curves(plane: &Plane, pieces: &[Piece]) -> Result<Vec<Curve>> {
|
fn curves(plane: &Plane, pieces: &[Piece]) -> Result<Vec<Curve>> {
|
||||||
pieces.iter().map(|x| curve(plane, &x.seg)).collect()
|
let mut out = Vec::new();
|
||||||
|
let mut run = Vec::new();
|
||||||
|
for x in pieces {
|
||||||
|
if !x.joins_previous && !run.is_empty() {
|
||||||
|
out.push(joined(std::mem::take(&mut run))?);
|
||||||
|
}
|
||||||
|
run.push(curve(plane, &x.seg)?);
|
||||||
|
}
|
||||||
|
if !run.is_empty() {
|
||||||
|
out.push(joined(run)?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bézier curves of one degree, each starting where the one before ends, as the one B-spline
|
||||||
|
/// they are, exactly: their control points in a row (each joint once) on knots repeated the
|
||||||
|
/// degree times at each joint. A rational piece's weights are scaled (which leaves it the same
|
||||||
|
/// curve) to meet the weight the piece before ends with. Each piece's knot span is as long as
|
||||||
|
/// keeps the speed through the joint the same on both sides, so pieces meeting tangentially
|
||||||
|
/// (the pieces of one ellipse or spline) join with a continuous first derivative: SMLib makes
|
||||||
|
/// one wall over a curve that is, and splits it where the derivative jumps.
|
||||||
|
fn joined(run: Vec<Curve>) -> Result<Curve> {
|
||||||
|
if run.len() == 1 {
|
||||||
|
return run.into_iter().next().ok_or_else(|| KernelError::Invalid("an empty run of curves".into()));
|
||||||
|
}
|
||||||
|
let not_one = || KernelError::Invalid("the pieces of one curve are not Béziers of one degree".into());
|
||||||
|
let xyz = |q: &[f64; 4]| Vec3::new(q[0], q[1], q[2]);
|
||||||
|
let mut degree = None;
|
||||||
|
let mut points: Vec<[f64; 4]> = Vec::new();
|
||||||
|
let mut knots = Vec::new();
|
||||||
|
// The knot at the last joint, and the length of the span before it.
|
||||||
|
let (mut at, mut span) = (0.0, 1.0);
|
||||||
|
for c in &run {
|
||||||
|
let Curve::Nurbs { degree: d, points: ps, .. } = c else { return Err(not_one()) };
|
||||||
|
if degree.is_some_and(|p| p != *d) || ps.len() != d + 1 || *d == 0 {
|
||||||
|
return Err(not_one());
|
||||||
|
}
|
||||||
|
degree = Some(*d);
|
||||||
|
let (Some(first), Some(second)) = (ps.first(), ps.get(1)) else { return Err(not_one()) };
|
||||||
|
let (Some(last), Some(before)) = (points.last(), points.len().checked_sub(2).and_then(|i| points.get(i))) else {
|
||||||
|
points.extend(ps.iter().copied());
|
||||||
|
knots.extend(std::iter::repeat_n(0.0, d + 1));
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let scale = last[3] / first[3];
|
||||||
|
if !(scale.is_finite() && scale > 0.0) {
|
||||||
|
return Err(not_one());
|
||||||
|
}
|
||||||
|
// The derivative of a rational Bézier on a span of length h at its end is
|
||||||
|
// d (w[d-1] / w[d]) (P[d] - P[d-1]) / h, at its start d (w[1] / w[0]) (P[1] - P[0]) / h.
|
||||||
|
let ending = (xyz(last) - xyz(before)) * (before[3] / last[3]) * (1.0 / span);
|
||||||
|
let starting = (xyz(second) - xyz(first)) * (second[3] / first[3]);
|
||||||
|
let next = starting.len() / ending.len();
|
||||||
|
at += span;
|
||||||
|
span = if next.is_finite() && next > 0.0 { next } else { 1.0 };
|
||||||
|
points.extend(ps.iter().skip(1).map(|q| [q[0], q[1], q[2], q[3] * scale]));
|
||||||
|
knots.extend(std::iter::repeat_n(at, *d));
|
||||||
|
}
|
||||||
|
let degree = degree.ok_or_else(not_one)?;
|
||||||
|
knots.extend(std::iter::repeat_n(at + span, degree + 1));
|
||||||
|
Ok(Curve::Nurbs { degree, points, knots })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A chain of sketch segments on `plane` as SMLib's curves, one each, exactly (lines, arcs,
|
/// A chain of sketch segments on `plane` as SMLib's curves, one each, exactly (lines, arcs,
|
||||||
@@ -564,11 +632,26 @@ mod imp {
|
|||||||
};
|
};
|
||||||
crate::FaceSource::ExtrudeSide { segment: x.drawn, plane, coalesced: false }
|
crate::FaceSource::ExtrudeSide { segment: x.drawn, plane, coalesced: false }
|
||||||
}),
|
}),
|
||||||
|
// The one wall over a whole run of pieces of one curve: named by its first piece.
|
||||||
|
Role::Wall(set) if run_starts(pieces, set).is_some() => run_starts(pieces, set)
|
||||||
|
.and_then(|i| pieces.get(i))
|
||||||
|
.map(|x| crate::FaceSource::ExtrudeSide { segment: x.drawn, plane: None, coalesced: false }),
|
||||||
_ => None,
|
_ => None,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The first piece of the run of joined pieces `set` is exactly (all of it, and nothing else).
|
||||||
|
fn run_starts(pieces: &[Piece], set: &BTreeSet<usize>) -> Option<usize> {
|
||||||
|
let (&first, &last) = (set.first()?, set.last()?);
|
||||||
|
let run = pieces.get(first..=last)?;
|
||||||
|
let whole = last - first + 1 == set.len()
|
||||||
|
&& run.first().is_some_and(|x| !x.joins_previous)
|
||||||
|
&& run.iter().skip(1).all(|x| x.joins_previous)
|
||||||
|
&& pieces.get(last + 1).is_none_or(|x| !x.joins_previous);
|
||||||
|
(set.len() > 1 && whole).then_some(first)
|
||||||
|
}
|
||||||
|
|
||||||
fn numbered_segments(r: &Region2) -> usize {
|
fn numbered_segments(r: &Region2) -> usize {
|
||||||
r.outer.segs.len() + r.holes.iter().map(|h| h.segs.len()).sum::<usize>()
|
r.outer.segs.len() + r.holes.iter().map(|h| h.segs.len()).sum::<usize>()
|
||||||
}
|
}
|
||||||
@@ -595,7 +678,7 @@ mod imp {
|
|||||||
let walls: Vec<Piece> = pieces
|
let walls: Vec<Piece> = pieces
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|x| [0.0, 0.5, 1.0].iter().any(|t| off_axis(plane.to_world(x.seg.point_at(*t)))))
|
.filter(|x| [0.0, 0.5, 1.0].iter().any(|t| off_axis(plane.to_world(x.seg.point_at(*t)))))
|
||||||
.map(|x| Piece { seg: x.seg, numbers: x.numbers.clone(), drawn: x.drawn })
|
.map(|x| Piece { seg: x.seg, numbers: x.numbers.clone(), drawn: x.drawn, joins_previous: false })
|
||||||
.collect();
|
.collect();
|
||||||
let about = |q: Vec3, a: f64| origin + turned(q - origin, k, a);
|
let about = |q: Vec3, a: f64| origin + turned(q - origin, k, a);
|
||||||
let full = turn >= std::f64::consts::TAU - 1e-9;
|
let full = turn >= std::f64::consts::TAU - 1e-9;
|
||||||
@@ -1535,7 +1618,7 @@ mod imp {
|
|||||||
all.extend(q.numbers.iter().copied());
|
all.extend(q.numbers.iter().copied());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Piece { seg: x.seg, numbers: all.into_iter().collect(), drawn: x.drawn }
|
Piece { seg: x.seg, numbers: all.into_iter().collect(), drawn: x.drawn, joins_previous: false }
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let roles = roles(&body, start, Some(&last.0), &seeds(start, &columns, |q| q), &[], false)?;
|
let roles = roles(&body, start, Some(&last.0), &seeds(start, &columns, |q| q), &[], false)?;
|
||||||
|
|||||||
@@ -331,14 +331,14 @@ fn fillet_max_finds_the_largest_radius_that_works() {
|
|||||||
let b = box_solid(Vec3::new(14.0, 0.0, 0.0), Vec3::new(28.0, 6.0, 10.0)).unwrap();
|
let b = box_solid(Vec3::new(14.0, 0.0, 0.0), Vec3::new(28.0, 6.0, 10.0)).unwrap();
|
||||||
let mids = [Vec3::new(14.0, 0.0, 5.0), Vec3::new(28.0, 0.0, 5.0), Vec3::new(14.0, 6.0, 5.0), Vec3::new(28.0, 6.0, 5.0)];
|
let mids = [Vec3::new(14.0, 0.0, 5.0), Vec3::new(28.0, 0.0, 5.0), Vec3::new(14.0, 6.0, 5.0), Vec3::new(28.0, 6.0, 5.0)];
|
||||||
assert!(too_large(&b, &mids, 3.5));
|
assert!(too_large(&b, &mids, 3.5));
|
||||||
let (r, body) = fillet_max(&b, &mids, 3.5).unwrap().unwrap();
|
let (r, body) = fillet_max(&b, &mids, 3.5, FilletTurn::Mitre).unwrap().unwrap();
|
||||||
assert_eq!(r, 3.0);
|
assert_eq!(r, 3.0);
|
||||||
assert!(rel(measure(&body).unwrap().volume, 840.0 - (4.0 - PI) * 90.0) < 2e-4);
|
assert!(rel(measure(&body).unwrap().volume, 840.0 - (4.0 - PI) * 90.0) < 2e-4);
|
||||||
// No edge there: the fillet's own error, not a smaller radius (only a refusal as too large
|
// No edge there: the fillet's own error, not a smaller radius (only a refusal as too large
|
||||||
// narrows the search).
|
// narrows the search).
|
||||||
let e = fillet(&b, &[Vec3::new(500.0, 0.0, 5.0)], 1.0).unwrap_err();
|
let e = fillet(&b, &[Vec3::new(500.0, 0.0, 5.0)], 1.0).unwrap_err();
|
||||||
assert!(!matches!(e, KernelError::TooLarge(_)), "{e}");
|
assert!(!matches!(e, KernelError::TooLarge(_)), "{e}");
|
||||||
assert_eq!(fillet_max(&b, &[Vec3::new(500.0, 0.0, 5.0)], 1.0).unwrap_err().to_string(), e.to_string());
|
assert_eq!(fillet_max(&b, &[Vec3::new(500.0, 0.0, 5.0)], 1.0, FilletTurn::Mitre).unwrap_err().to_string(), e.to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Edges that share no face get their own largest radius: a 2 mm fin's top edge takes at most
|
/// Edges that share no face get their own largest radius: a 2 mm fin's top edge takes at most
|
||||||
@@ -360,7 +360,7 @@ fn fillet_max_each_reduces_only_where_the_radius_does_not_fit() {
|
|||||||
// at its edge less the quarter disc about (17, 26), inside the fin (x >= 19): 2 x 4 less the
|
// at its edge less the quarter disc about (17, 26), inside the fin (x >= 19): 2 x 4 less the
|
||||||
// disc's part, in closed form; the corner's round (1 − π/4)·16 along 20.
|
// disc's part, in closed form; the corner's round (1 − π/4)·16 along 20.
|
||||||
let both = fillet(&body, &edges, 4.0).unwrap();
|
let both = fillet(&body, &edges, 4.0).unwrap();
|
||||||
let (radii, b) = fillet_max_each(&body, &edges, 4.0).unwrap().unwrap();
|
let (radii, b) = fillet_max_each(&body, &edges, 4.0, FilletTurn::Mitre).unwrap().unwrap();
|
||||||
assert_eq!(radii, vec![4.0, 4.0], "{radii:?}");
|
assert_eq!(radii, vec![4.0, 4.0], "{radii:?}");
|
||||||
assert!(b.validity().is_empty(), "{:?}", b.validity());
|
assert!(b.validity().is_empty(), "{:?}", b.validity());
|
||||||
let disc = |a: f64| 0.5 * (a * (16.0 - a * a).max(0.0).sqrt() + 16.0 * (a / 4.0).asin());
|
let disc = |a: f64| 0.5 * (a * (16.0 - a * a).max(0.0).sqrt() + 16.0 * (a / 4.0).asin());
|
||||||
@@ -371,7 +371,7 @@ fn fillet_max_each_reduces_only_where_the_radius_does_not_fit() {
|
|||||||
// The post's four edges limit each other: one radius, as `fillet_max`.
|
// The post's four edges limit each other: one radius, as `fillet_max`.
|
||||||
let post = box_solid(Vec3::new(14.0, 0.0, 0.0), Vec3::new(28.0, 6.0, 10.0)).unwrap();
|
let post = box_solid(Vec3::new(14.0, 0.0, 0.0), Vec3::new(28.0, 6.0, 10.0)).unwrap();
|
||||||
let mids = [Vec3::new(14.0, 0.0, 5.0), Vec3::new(28.0, 0.0, 5.0), Vec3::new(14.0, 6.0, 5.0), Vec3::new(28.0, 6.0, 5.0)];
|
let mids = [Vec3::new(14.0, 0.0, 5.0), Vec3::new(28.0, 0.0, 5.0), Vec3::new(14.0, 6.0, 5.0), Vec3::new(28.0, 6.0, 5.0)];
|
||||||
assert_eq!(fillet_max_each(&post, &mids, 3.5).unwrap().map(|x| x.0), Some(vec![3.0; 4]));
|
assert_eq!(fillet_max_each(&post, &mids, 3.5, FilletTurn::Mitre).unwrap().map(|x| x.0), Some(vec![3.0; 4]));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// R21 on an edge of a 20 mm cube overflows on its tangent sides, which a round may, taking all
|
/// R21 on an edge of a 20 mm cube overflows on its tangent sides, which a round may, taking all
|
||||||
|
|||||||
@@ -3480,9 +3480,9 @@ fn a_bridged_server_checks_calls_at_its_param_check_level() {
|
|||||||
assert_eq!(r["result"]["isError"], json!(true), "{r}");
|
assert_eq!(r["result"]["isError"], json!(true), "{r}");
|
||||||
assert!(text(&r).contains(&format!("invalid parameters for `solid.box`: {warning}")), "{r}");
|
assert!(text(&r).contains(&format!("invalid parameters for `solid.box`: {warning}")), "{r}");
|
||||||
assert_eq!(sent("engine.execute solid.box"), 0, "refused unsent");
|
assert_eq!(sent("engine.execute solid.box"), 0, "refused unsent");
|
||||||
let r = raw_tool(&mut s, "execute", json!({"command": "solid.fillet", "params": {"nonsense": 1}}));
|
let r = raw_tool(&mut s, "execute", json!({"command": "solid.chamfer", "params": {"nonsense": 1}}));
|
||||||
assert_ne!(r["result"]["isError"], json!(true), "{r}");
|
assert_ne!(r["result"]["isError"], json!(true), "{r}");
|
||||||
assert_eq!(sent("engine.execute solid.fillet"), 1, "no contract here: the app decides");
|
assert_eq!(sent("engine.execute solid.chamfer"), 1, "no contract here: the app decides");
|
||||||
let r = raw_tool(&mut s, "batch", json!({"commands": [good, misspelt]}));
|
let r = raw_tool(&mut s, "batch", json!({"commands": [good, misspelt]}));
|
||||||
assert_eq!(r["result"]["isError"], json!(true), "{r}");
|
assert_eq!(r["result"]["isError"], json!(true), "{r}");
|
||||||
let reply: Value = serde_json::from_str(&text(&r)).unwrap();
|
let reply: Value = serde_json::from_str(&text(&r)).unwrap();
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ use solidcore::topology::topology_traverser::Marks;
|
|||||||
use solidcore::topology::{Brep, EdgeId, EdgeuseId, FaceId, FaceuseId, VertexId, edge, vertex};
|
use solidcore::topology::{Brep, EdgeId, EdgeuseId, FaceId, FaceuseId, VertexId, edge, vertex};
|
||||||
|
|
||||||
use super::{Body, answer};
|
use super::{Body, answer};
|
||||||
use crate::{Blend, ChamferSecond, Error, Hole, HoleEdge, MAX_POINTS, Result};
|
use crate::{Blend, ChamferSecond, Error, Hole, HoleEdge, MAX_POINTS, Result, Turn};
|
||||||
|
|
||||||
/// Why solidcore refuses the 3x2 mixed-convexity corner whose rails cross
|
/// Why solidcore refuses the 3x2 mixed-convexity corner whose rails cross
|
||||||
/// (`fixes.fillet_refuse_mixed_corner_crossing_rails`, [`MIXED_CORNER_RAILS_CROSS`]): it builds no
|
/// (`fixes.fillet_refuse_mixed_corner_crossing_rails`, [`MIXED_CORNER_RAILS_CROSS`]): it builds no
|
||||||
@@ -248,7 +248,9 @@ fn chamfer_radius(k: &Kernel, b: &mut Brep, e: EdgeId, distance: f64) -> solidco
|
|||||||
/// What `round_edges` makes: the capi's kinds less the chamfer, which `chamfer_offsets` makes.
|
/// What `round_edges` makes: the capi's kinds less the chamfer, which `chamfer_offsets` makes.
|
||||||
#[derive(Clone, Copy, PartialEq)]
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
enum Section<'a> {
|
enum Section<'a> {
|
||||||
Round,
|
/// A circular section of constant radius (or of a variable radius's law), the turns of two
|
||||||
|
/// such rounds at a side edge as the [`Turn`] says (`SetSweptTurns`).
|
||||||
|
Round(Turn),
|
||||||
/// The curvature-continuous section (`SM_FSG_BLEND_CURVE`): the Gⁿ generator's order 2 at the
|
/// The curvature-continuous section (`SM_FSG_BLEND_CURVE`): the Gⁿ generator's order 2 at the
|
||||||
/// equal-area thumbweight (`SMLIB_BLEND_THUMBWEIGHT`, fork 6148773; solidcore#2261 under
|
/// equal-area thumbweight (`SMLIB_BLEND_THUMBWEIGHT`, fork 6148773; solidcore#2261 under
|
||||||
/// `fillet_gn_section_generator`), which takes off what the ball's circle does.
|
/// `fillet_gn_section_generator`), which takes off what the ball's circle does.
|
||||||
@@ -338,6 +340,23 @@ fn room_reason(r: NoRoomReason) -> Option<crate::RoomReason> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The refusal of a swept turn `CreateFilletCorners` noted at its vertex (`SmBlendCorner::Classify`
|
||||||
|
/// with `GetSweptTurns()`): [`Error::TurnRefused`] with solidcore's reason, without its "Fillet
|
||||||
|
/// Error: " prefix, and where it is; `None` for a note of another kind.
|
||||||
|
fn turn_refusal(info: &ErrorInfo<FilletObject>) -> Option<Error> {
|
||||||
|
let room = match info.code {
|
||||||
|
c if c == FilletErrorType::NoRoom as i32 => true,
|
||||||
|
c if c == FilletErrorType::CornerUnsupported as i32 => false,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let why = info.comment.trim();
|
||||||
|
if why.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let why = why.strip_prefix("Fillet Error:").map_or(why, str::trim_start).to_owned();
|
||||||
|
Some(Error::TurnRefused { room, why, at: info.location.map(|p| [p.x, p.y, p.z]) })
|
||||||
|
}
|
||||||
|
|
||||||
/// [`blend_failure`] where solidcore gives no size.
|
/// [`blend_failure`] where solidcore gives no size.
|
||||||
fn blend_failure_unsized(info: &ErrorInfo<FilletObject>, e: SolidError, corner: bool) -> Error {
|
fn blend_failure_unsized(info: &ErrorInfo<FilletObject>, e: SolidError, corner: bool) -> Error {
|
||||||
if e.is_unported() {
|
if e.is_unported() {
|
||||||
@@ -433,13 +452,16 @@ fn blend_edges_stopping(
|
|||||||
let mut fit_edge = None;
|
let mut fit_edge = None;
|
||||||
let done = {
|
let done = {
|
||||||
let mut s = FilletSession::new(k, b)?;
|
let mut s = FilletSession::new(k, b)?;
|
||||||
|
// `SetSweptTurns`: read as the corners are made (`fixes.fillet_blend_corner`).
|
||||||
|
let swept = section == Section::Round(Turn::Swept);
|
||||||
|
s.set_swept_turns(swept);
|
||||||
// `SetFilletParameters(b, es, sections, solvers, values, laws, -1, -1, -1, -1,
|
// `SetFilletParameters(b, es, sections, solvers, values, laws, -1, -1, -1, -1,
|
||||||
// continuity 1, thumbweight 1)`: the session sets the executive's global merge first, as
|
// continuity 1, thumbweight 1)`: the session sets the executive's global merge first, as
|
||||||
// `SmApiFilletEdges` does, and `SetFilletParameters` resets it, as in the capi.
|
// `SmApiFilletEdges` does, and `SetFilletParameters` resets it, as in the capi.
|
||||||
let prepared = match (section, law) {
|
let prepared = match (section, law) {
|
||||||
(Section::Round, None) => s.prepare_per_edge_constant_radius(k, edges, sizes, &[FilletSurfaceGeneratorType::Circular], 1, 1.0),
|
(Section::Round(_), None) => s.prepare_per_edge_constant_radius(k, edges, sizes, &[FilletSurfaceGeneratorType::Circular], 1, 1.0),
|
||||||
// The size is the start radius (`smlib_fillet_variable`).
|
// The size is the start radius (`smlib_fillet_variable`).
|
||||||
(Section::Round, Some(law)) => match sizes.first() {
|
(Section::Round(_), Some(law)) => match sizes.first() {
|
||||||
Some(&size) => s.prepare_variable_radius_edges(k, edges, size, law, 1, 1),
|
Some(&size) => s.prepare_variable_radius_edges(k, edges, size, law, 1, 1),
|
||||||
None => return Ok(Err(Err(Error::Argument))),
|
None => return Ok(Err(Err(Error::Argument))),
|
||||||
},
|
},
|
||||||
@@ -459,7 +481,10 @@ fn blend_edges_stopping(
|
|||||||
s.add_set_back_vertex(sb.vertex);
|
s.add_set_back_vertex(sb.vertex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let prepared_ok = prepared.is_ok();
|
||||||
let mut step = prepared.and_then(|()| s.create_corners(k, None)).map(|()| Ok(Vec::new()));
|
let mut step = prepared.and_then(|()| s.create_corners(k, None)).map(|()| Ok(Vec::new()));
|
||||||
|
// A swept turn `CreateFilletCorners` refused, with solidcore's reason ([`turn_refusal`]).
|
||||||
|
let refused_turn = if swept && prepared_ok && step.is_err() { turn_refusal(s.error_info()) } else { None };
|
||||||
// A vertex `CreateFilletCorners` made no corner for, noted before a later step notes its
|
// A vertex `CreateFilletCorners` made no corner for, noted before a later step notes its
|
||||||
// own failure over it ([`blend_failure`]).
|
// own failure over it ([`blend_failure`]).
|
||||||
let corner = step.is_ok() && s.error_info().code == FilletErrorType::CornerUnsupported as i32;
|
let corner = step.is_ok() && s.error_info().code == FilletErrorType::CornerUnsupported as i32;
|
||||||
@@ -488,11 +513,14 @@ fn blend_edges_stopping(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
Ok(Err(refusal)) => Ok(Err(refusal)),
|
Ok(Err(refusal)) => Ok(Err(refusal)),
|
||||||
Err(e) => {
|
Err(e) => match refused_turn {
|
||||||
let (why, edge) = blend_failure(&s, e, corner);
|
Some(why) => Ok(Err(Err(why))),
|
||||||
fit_edge = edge;
|
None => {
|
||||||
Ok(Err(Err(why)))
|
let (why, edge) = blend_failure(&s, e, corner);
|
||||||
}
|
fit_edge = edge;
|
||||||
|
Ok(Err(Err(why)))
|
||||||
|
}
|
||||||
|
},
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let done: solidcore::Result<std::result::Result<(Vec<usize>, Vec<(usize, FaceId)>), std::result::Result<usize, Error>>> = done;
|
let done: solidcore::Result<std::result::Result<(Vec<usize>, Vec<(usize, FaceId)>), std::result::Result<usize, Error>>> = done;
|
||||||
@@ -825,9 +853,9 @@ pub(crate) fn blend(
|
|||||||
|
|
||||||
/// `smlib_blend_dropping`: [`blend`] with one size per point, no corners, and per point whether it
|
/// `smlib_blend_dropping`: [`blend`] with one size per point, no corners, and per point whether it
|
||||||
/// picked an edge that a larger round, overflowing its faces, removed whole.
|
/// picked an edge that a larger round, overflowing its faces, removed whole.
|
||||||
pub(crate) fn blend_dropping(body: &Body, kind: Blend, points: &[[f64; 3]], sizes: &[f64], tolerance: f64) -> Result<(Body, Vec<bool>)> {
|
pub(crate) fn blend_dropping(body: &Body, kind: Blend, turn: Turn, points: &[[f64; 3]], sizes: &[f64], tolerance: f64) -> Result<(Body, Vec<bool>)> {
|
||||||
let mut dropped = vec![false; points.len()];
|
let mut dropped = vec![false; points.len()];
|
||||||
let (b, _) = blend_call(body, kind, points, sizes, tolerance, &[], &[], Corners::Filled, &mut dropped)?;
|
let (b, _) = blend_call(body, kind, turn, points, sizes, tolerance, &[], &[], Corners::Filled, &mut dropped)?;
|
||||||
Ok((b, dropped))
|
Ok((b, dropped))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -846,15 +874,17 @@ pub(crate) fn blend_holes(
|
|||||||
setbacks: &[f64],
|
setbacks: &[f64],
|
||||||
open: Corners,
|
open: Corners,
|
||||||
) -> Result<(Body, Vec<Hole>)> {
|
) -> Result<(Body, Vec<Hole>)> {
|
||||||
blend_call(body, kind, points, sizes, tolerance, corners, setbacks, open, &mut vec![false; points.len()])
|
blend_call(body, kind, Turn::Mitre, points, sizes, tolerance, corners, setbacks, open, &mut vec![false; points.len()])
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `blend_call`: [`blend_holes`], with per point in `dropped` whether it picked an edge an
|
/// `blend_call`: [`blend_holes`], with per point in `dropped` whether it picked an edge an
|
||||||
/// overflowing round removed whole ([`overflow_first`]).
|
/// overflowing round removed whole ([`overflow_first`]), and the rounds' turns at side edges as
|
||||||
|
/// `turn` says (a swept turn is for [`Blend::Round`] only).
|
||||||
#[allow(clippy::too_many_arguments)] // the capi's blend_call
|
#[allow(clippy::too_many_arguments)] // the capi's blend_call
|
||||||
fn blend_call(
|
fn blend_call(
|
||||||
body: &Body,
|
body: &Body,
|
||||||
kind: Blend,
|
kind: Blend,
|
||||||
|
turn: Turn,
|
||||||
points: &[[f64; 3]],
|
points: &[[f64; 3]],
|
||||||
sizes: &[f64],
|
sizes: &[f64],
|
||||||
tolerance: f64,
|
tolerance: f64,
|
||||||
@@ -869,6 +899,7 @@ fn blend_call(
|
|||||||
|| !(one_size || sizes.len() == points.len())
|
|| !(one_size || sizes.len() == points.len())
|
||||||
|| !points_ok(corners, true)
|
|| !points_ok(corners, true)
|
||||||
|| setbacks.len() != corners.len()
|
|| setbacks.len() != corners.len()
|
||||||
|
|| (turn == Turn::Swept && kind != Blend::Round)
|
||||||
{
|
{
|
||||||
return Err(Error::Argument);
|
return Err(Error::Argument);
|
||||||
}
|
}
|
||||||
@@ -898,7 +929,7 @@ fn blend_call(
|
|||||||
}
|
}
|
||||||
let solid_in = brep_p2::is_manifold_solid(&w)?;
|
let solid_in = brep_p2::is_manifold_solid(&w)?;
|
||||||
let section = match kind {
|
let section = match kind {
|
||||||
Blend::Round => Section::Round,
|
Blend::Round => Section::Round(turn),
|
||||||
// Every edge of one call takes the equal-area thumbweight of the first's wedge
|
// Every edge of one call takes the equal-area thumbweight of the first's wedge
|
||||||
// (solidcore#2250's comment 6563419897): edges of other wedges would take off another
|
// (solidcore#2250's comment 6563419897): edges of other wedges would take off another
|
||||||
// area than their own, so they are not built together.
|
// area than their own, so they are not built together.
|
||||||
@@ -1380,7 +1411,7 @@ pub(crate) fn fillet_variable(
|
|||||||
// (`NoRoomReason::BallsFold`), [`Error::NoRoom`].
|
// (`NoRoomReason::BallsFold`), [`Error::NoRoom`].
|
||||||
let solid_in = brep_p2::is_manifold_solid(&w)?;
|
let solid_in = brep_p2::is_manifold_solid(&w)?;
|
||||||
let (r0, r1) = (radii.first().copied().unwrap_or(0.0), radii.last().copied().unwrap_or(0.0));
|
let (r0, r1) = (radii.first().copied().unwrap_or(0.0), radii.last().copied().unwrap_or(0.0));
|
||||||
Ok(match blend_edges(k, &mut w, &[e], &[r0.max(r1)], Section::Round, Some(law), &[], None)? {
|
Ok(match blend_edges(k, &mut w, &[e], &[r0.max(r1)], Section::Round(Turn::Mitre), Some(law), &[], None)? {
|
||||||
Ok(()) => finish(w, solid_in)?,
|
Ok(()) => finish(w, solid_in)?,
|
||||||
Err(Ok(_)) => Err(Error::Unsupported),
|
Err(Ok(_)) => Err(Error::Unsupported),
|
||||||
Err(Err(e)) => Err(e),
|
Err(Err(e)) => Err(e),
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use super::blend::{Corners, blend, blend_dropping, blend_holes, chamfer_sides, f
|
|||||||
use super::boolean::boolean;
|
use super::boolean::boolean;
|
||||||
use super::{Body, counts, make_box, make_cylinder, to_arrays, volume};
|
use super::{Body, counts, make_box, make_cylinder, to_arrays, volume};
|
||||||
use crate::export::native;
|
use crate::export::native;
|
||||||
use crate::{Blend, BooleanOp, ChamferSecond, Error};
|
use crate::{Blend, BooleanOp, ChamferSecond, Error, Turn};
|
||||||
|
|
||||||
/// A case longer than this is a hang.
|
/// A case longer than this is a hang.
|
||||||
const TIMEOUT: Duration = Duration::from_secs(300);
|
const TIMEOUT: Duration = Duration::from_secs(300);
|
||||||
@@ -399,7 +399,7 @@ fn overflow_goes_first() {
|
|||||||
];
|
];
|
||||||
for (points, sizes, dropped, want, bound) in cases {
|
for (points, sizes, dropped, want, bound) in cases {
|
||||||
let got = timed("overflow_first", move || {
|
let got = timed("overflow_first", move || {
|
||||||
let (b, d) = blend_dropping(&Shape::Box([0.0; 3], [20.0; 3]).core(), Blend::Round, points, sizes, 1.0).unwrap();
|
let (b, d) = blend_dropping(&Shape::Box([0.0; 3], [20.0; 3]).core(), Blend::Round, Turn::Mitre, points, sizes, 1.0).unwrap();
|
||||||
native(&to_arrays(&b).unwrap()).unwrap();
|
native(&to_arrays(&b).unwrap()).unwrap();
|
||||||
(volume(&b, 1e-8).unwrap(), d)
|
(volume(&b, 1e-8).unwrap(), d)
|
||||||
});
|
});
|
||||||
@@ -445,6 +445,71 @@ fn rounds_at_a_concave_face_corner_are_mitred() {
|
|||||||
assert!(rel(got, v) < 1e-8, "{got} vs {v}");
|
assert!(rel(got, v) < 1e-8, "{got} vs {v}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A 40 x 30 x 10 plate with a 10 x 10 x 5 boss on it (x 15..25, y 10..20), the boss's four base
|
||||||
|
/// edges rounded at r 1: at each upright boss edge two concave rounds turn round the sharp convex
|
||||||
|
/// edge. Each base edge gains the spandrel A = (1 - π/4) r² along its 10, and each corner the
|
||||||
|
/// rounds' part beyond both walls, whose section's first moment about the upright edge is
|
||||||
|
/// M = (5/6 - π/4) r³: mitred, 8 M in all; swept, the section revolved a quarter turn at each,
|
||||||
|
/// 2π M, the corner a horn torus of radii r, r. Swept with unequal radii, or along a pocket's floor
|
||||||
|
/// (its upright edges sharp and concave), is refused with solidcore's reason.
|
||||||
|
#[test]
|
||||||
|
fn rounds_turning_round_a_boss_edge_mitred_or_swept() {
|
||||||
|
let made = |a: Body, b: Body, op| boolean(&a, &b, op).unwrap().unwrap();
|
||||||
|
let boss =
|
||||||
|
move || made(make_box([0.0; 3], [40.0, 30.0, 10.0]).unwrap(), make_box([15.0, 10.0, 10.0], [10.0, 10.0, 5.0]).unwrap(), BooleanOp::Union);
|
||||||
|
let pocket =
|
||||||
|
move || made(make_box([0.0; 3], [40.0, 30.0, 10.0]).unwrap(), make_box([10.0, 10.0, 5.0], [20.0, 10.0, 6.0]).unwrap(), BooleanOp::Subtract);
|
||||||
|
const BASE: [[f64; 3]; 4] = [[20.0, 10.0, 10.0], [25.0, 15.0, 10.0], [20.0, 20.0, 10.0], [15.0, 15.0, 10.0]];
|
||||||
|
const RIM: [[f64; 3]; 4] = [[20.0, 10.0, 5.0], [30.0, 15.0, 5.0], [20.0, 20.0, 5.0], [10.0, 15.0, 5.0]];
|
||||||
|
let r = 1.0;
|
||||||
|
let rounded =
|
||||||
|
move |body: Body, at: [[f64; 3]; 4], turn, sizes: [f64; 4]| blend_dropping(&body, Blend::Round, turn, &at, &sizes, 0.1).map(|(b, _)| b);
|
||||||
|
// solidcore's tori, and the record's kinds of surface: its torus is a ring torus, so a horn
|
||||||
|
// torus's face there is its exact rational form.
|
||||||
|
let measured = |b: &Body| {
|
||||||
|
use solidcore::surface::surface::p2::SurfaceKind;
|
||||||
|
let (rec, _) = native(&to_arrays(b).unwrap()).unwrap();
|
||||||
|
rec.validate().unwrap();
|
||||||
|
let mut tori = Vec::new();
|
||||||
|
{
|
||||||
|
let g = b.0.lock().unwrap();
|
||||||
|
for f in solidcore::query::api_queries::get_faces(&g).unwrap() {
|
||||||
|
if let Some(s) = solidcore::topology::face::p2::surface(&g, f).unwrap()
|
||||||
|
&& let SurfaceKind::Torus(t) = s.kind()
|
||||||
|
{
|
||||||
|
tori.push((t.major_radius(), t.minor_radius()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(volume(b, 1e-10).unwrap(), counts(b).unwrap()[0], tori, kinds(&rec))
|
||||||
|
};
|
||||||
|
let (mitre, swept) =
|
||||||
|
timed("turn", move || (rounded(boss(), BASE, Turn::Mitre, [r; 4]).unwrap(), rounded(boss(), BASE, Turn::Swept, [r; 4]).unwrap()));
|
||||||
|
let (vm, fm, tm, km) = measured(&mitre);
|
||||||
|
let (vs, fs, ts, ks) = measured(&swept);
|
||||||
|
println!("mitre: {vm:.9}, {fm} faces {km:?}; swept: {vs:.9}, {fs} faces {ks:?}, tori {ts:?}");
|
||||||
|
let (a, m) = (spandrel(r), (5.0 / 6.0 - PI / 4.0) * r * r * r);
|
||||||
|
let base = 12500.0 + 40.0 * a;
|
||||||
|
assert!(rel(vm, base + 8.0 * m) < 1e-9, "mitre {vm} vs {}", base + 8.0 * m);
|
||||||
|
assert!(rel(vs, base + 2.0 * PI * m) < 1e-9, "swept {vs} vs {}", base + 2.0 * PI * m);
|
||||||
|
assert_eq!((fm, tm.len(), km.get("rational_spline")), (15, 0, None));
|
||||||
|
assert_eq!((fs, ts.len(), ks.get("rational_spline")), (19, 4, Some(&4)));
|
||||||
|
assert!(ts.iter().all(|(major, minor)| (major - r).abs() < 1e-12 && (minor - r).abs() < 1e-12), "{ts:?}");
|
||||||
|
// Swept is for rounds only.
|
||||||
|
assert_eq!(blend_dropping(&boss(), Blend::Chamfer, Turn::Swept, &BASE, &[r; 4], 0.1).err(), Some(Error::Argument));
|
||||||
|
let unequal = timed("turn unequal", move || rounded(boss(), BASE, Turn::Swept, [1.0, 1.5, 1.0, 1.5]).err());
|
||||||
|
let rim = timed("turn rim", move || rounded(pocket(), RIM, Turn::Swept, [r; 4]).err());
|
||||||
|
println!("unequal: {unequal:?}\nrim: {rim:?}");
|
||||||
|
assert!(
|
||||||
|
matches!(&unequal, Some(Error::TurnRefused { room: false, why, at: Some(_) }) if why.starts_with("a swept turn is built between constant rounds of one radius")),
|
||||||
|
"{unequal:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
matches!(&rim, Some(Error::TurnRefused { room: true, why, at: Some(_) }) if why.contains("round that edge at a radius of at least 1 first")),
|
||||||
|
"{rim:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Solidcore alone: edge attributes across a cliff overflow. A 40 x 30 x 10 block notched below its
|
/// Solidcore alone: edge attributes across a cliff overflow. A 40 x 30 x 10 block notched below its
|
||||||
/// top at (40, 0), its front and right top edges rounded r8 (their rails run off over the notch).
|
/// top at (40, 0), its front and right top edges rounded r8 (their rails run off over the notch).
|
||||||
/// The back and left top edges are each trimmed 8 by those rounds: (0, 30)-(32, 30) and (0, 8)-(0, 30)
|
/// The back and left top edges are each trimmed 8 by those rounds: (0, 30)-(32, 30) and (0, 8)-(0, 30)
|
||||||
|
|||||||
+13
-1
@@ -25,7 +25,7 @@ pub mod trim;
|
|||||||
|
|
||||||
pub use check::{CheckLevel, CheckReport};
|
pub use check::{CheckLevel, CheckReport};
|
||||||
pub use measure::{MEASURE_ACCURACY, MassProperties, PointClass};
|
pub use measure::{MEASURE_ACCURACY, MassProperties, PointClass};
|
||||||
pub use ops::{Blend, BooleanOp, ChamferSecond, Hole, HoleEdge, MAX_POINTS, MAX_UNION_BREPS};
|
pub use ops::{Blend, BooleanOp, ChamferSecond, Hole, HoleEdge, MAX_POINTS, MAX_UNION_BREPS, Turn};
|
||||||
|
|
||||||
/// Why an SMLib call failed.
|
/// Why an SMLib call failed.
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
@@ -61,6 +61,11 @@ pub enum Error {
|
|||||||
Unsupported,
|
Unsupported,
|
||||||
/// A blend's edges meet at a vertex of a kind no SMLib blend corner handles yet.
|
/// A blend's edges meet at a vertex of a kind no SMLib blend corner handles yet.
|
||||||
CornerUnsupported,
|
CornerUnsupported,
|
||||||
|
/// A swept turn ([`Turn::Swept`]) solidcore does not build at the corner `at`: `room` where
|
||||||
|
/// no radius builds it (a sharp concave side edge between the rounds, solidcore's `NoRoom`),
|
||||||
|
/// else not supported there (`CornerUnsupported`: unequal or variable rounds, a corner of
|
||||||
|
/// four edges, a curved, drafted or tangent side edge); `why` is solidcore's reason.
|
||||||
|
TurnRefused { room: bool, why: String, at: Option<[f64; 3]> },
|
||||||
/// SMLib does not set back the corner of corner point `corner`: SMLib sets back a corner
|
/// SMLib does not set back the corner of corner point `corner`: SMLib sets back a corner
|
||||||
/// only where every edge meeting it is blended and they are all convex or all concave.
|
/// only where every edge meeting it is blended and they are all convex or all concave.
|
||||||
CornerRefused { corner: usize },
|
CornerRefused { corner: usize },
|
||||||
@@ -192,6 +197,13 @@ impl std::fmt::Display for Error {
|
|||||||
}
|
}
|
||||||
Error::Unsupported => write!(f, "not supported yet: SMLib cannot build this exactly"),
|
Error::Unsupported => write!(f, "not supported yet: SMLib cannot build this exactly"),
|
||||||
Error::CornerUnsupported => write!(f, "not supported yet: a corner of this kind is not supported yet by SMLib's blends"),
|
Error::CornerUnsupported => write!(f, "not supported yet: a corner of this kind is not supported yet by SMLib's blends"),
|
||||||
|
Error::TurnRefused { why, at, .. } => {
|
||||||
|
write!(f, "the swept corner is not built")?;
|
||||||
|
if let Some([x, y, z]) = at {
|
||||||
|
write!(f, " at ({x:.3}, {y:.3}, {z:.3})")?;
|
||||||
|
}
|
||||||
|
write!(f, ": {why}")
|
||||||
|
}
|
||||||
Error::CornerRefused { corner } => write!(
|
Error::CornerRefused { corner } => write!(
|
||||||
f,
|
f,
|
||||||
"not supported yet: SMLib does not set back corner {}: it sets back a corner only where every edge meeting it is blended, all convex or all concave",
|
"not supported yet: SMLib does not set back corner {}: it sets back a corner only where every edge meeting it is blended, all convex or all concave",
|
||||||
|
|||||||
+29
-4
@@ -58,6 +58,30 @@ impl Blend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How two constant rounds of one radius turn round a sharp, straight, convex side edge square to
|
||||||
|
/// both on a plane (the rounds along a boss's base, at its upright edges): the turn of
|
||||||
|
/// [`Brep::blend_dropping`]'s rounds ([`Blend::Round`] only).
|
||||||
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub enum Turn {
|
||||||
|
/// The rounds run on to where they cross and meet in a mitre, the plane section of the two.
|
||||||
|
#[default]
|
||||||
|
Mitre,
|
||||||
|
/// The rounds end on their sections at the side edge, and the corner's face is that section
|
||||||
|
/// revolved about the side edge, an exact torus tangent to both rounds. A corner where this
|
||||||
|
/// is not built is [`Error::TurnRefused`].
|
||||||
|
Swept,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Turn {
|
||||||
|
/// The turn's name in a trace ([`crate::trace`]).
|
||||||
|
pub fn word(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Turn::Mitre => "mitre",
|
||||||
|
Turn::Swept => "swept",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The second side of [`Brep::chamfer_sides`].
|
/// The second side of [`Brep::chamfer_sides`].
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub enum ChamferSecond {
|
pub enum ChamferSecond {
|
||||||
@@ -172,18 +196,19 @@ impl Brep {
|
|||||||
|
|
||||||
/// [`Brep::blend`], with whether each point picked an edge that a larger round, overflowing
|
/// [`Brep::blend`], with whether each point picked an edge that a larger round, overflowing
|
||||||
/// its faces, removed whole: that edge is gone and not rounded (SMLib rounds the edges whose
|
/// its faces, removed whole: that edge is gone and not rounded (SMLib rounds the edges whose
|
||||||
/// blends overflow first, then what is left of the others).
|
/// blends overflow first, then what is left of the others). Two rounds turn at a side edge as
|
||||||
pub fn blend_dropping(&self, kind: Blend, edges: &[[f64; 3]], sizes: &[f64], tolerance: f64) -> Result<(Brep, Vec<bool>)> {
|
/// `turn` says; [`Turn::Swept`] is for [`Blend::Round`] only ([`Error::Argument`] otherwise).
|
||||||
|
pub fn blend_dropping(&self, kind: Blend, turn: Turn, edges: &[[f64; 3]], sizes: &[f64], tolerance: f64) -> Result<(Brep, Vec<bool>)> {
|
||||||
if edges.len() > MAX_POINTS || edges.is_empty() || sizes.len() != edges.len() {
|
if edges.len() > MAX_POINTS || edges.is_empty() || sizes.len() != edges.len() {
|
||||||
return Err(Error::Argument);
|
return Err(Error::Argument);
|
||||||
}
|
}
|
||||||
let r = core::blend::blend_dropping(self.core(), kind, edges, sizes, tolerance).map(|(b, d)| (Brep::made_core(b), d));
|
let r = core::blend::blend_dropping(self.core(), kind, turn, edges, sizes, tolerance).map(|(b, d)| (Brep::made_core(b), d));
|
||||||
trace::call(
|
trace::call(
|
||||||
"blend_dropping",
|
"blend_dropping",
|
||||||
&[self],
|
&[self],
|
||||||
&r,
|
&r,
|
||||||
|(b, _)| vec![b.id],
|
|(b, _)| vec![b.id],
|
||||||
|| json!({"kind": kind.word(), "edges": trace::picks(self, PickKind::Edge, edges, tolerance), "sizes": sizes}),
|
|| json!({"kind": kind.word(), "turn": turn.word(), "edges": trace::picks(self, PickKind::Edge, edges, tolerance), "sizes": sizes}),
|
||||||
);
|
);
|
||||||
r
|
r
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ fn every_entry_point_is_in_the_guard() {
|
|||||||
fn every_entry_point_answers() {
|
fn every_entry_point_answers() {
|
||||||
use crate::sweeps::{Affine, Curve, DraftCorner, Ends, SweepFrame};
|
use crate::sweeps::{Affine, Curve, DraftCorner, Ends, SweepFrame};
|
||||||
use crate::trim::{Continuation, Holes};
|
use crate::trim::{Continuation, Holes};
|
||||||
use crate::{Blend, BooleanOp, ChamferSecond, CheckLevel};
|
use crate::{Blend, BooleanOp, ChamferSecond, CheckLevel, Turn};
|
||||||
fn says<T>(_name: &str, r: Result<T>) -> Option<T> {
|
fn says<T>(_name: &str, r: Result<T>) -> Option<T> {
|
||||||
r.ok()
|
r.ok()
|
||||||
}
|
}
|
||||||
@@ -222,7 +222,7 @@ fn every_entry_point_answers() {
|
|||||||
says("fillet", a.fillet(&edge, 0.1, 1.0));
|
says("fillet", a.fillet(&edge, 0.1, 1.0));
|
||||||
says("chamfer", a.chamfer(&edge, 0.1, 1.0));
|
says("chamfer", a.chamfer(&edge, 0.1, 1.0));
|
||||||
says("blend", a.blend(Blend::Round, &edge, &[1.0], 0.1));
|
says("blend", a.blend(Blend::Round, &edge, &[1.0], 0.1));
|
||||||
says("blend_dropping", a.blend_dropping(Blend::Round, &edge, &[1.0], 0.1));
|
says("blend_dropping", a.blend_dropping(Blend::Round, Turn::Mitre, &edge, &[1.0], 0.1));
|
||||||
let corner = [p(10.0, 10.0, 10.0)];
|
let corner = [p(10.0, 10.0, 10.0)];
|
||||||
let three = [p(5.0, 10.0, 10.0), p(10.0, 5.0, 10.0), p(10.0, 10.0, 5.0)];
|
let three = [p(5.0, 10.0, 10.0), p(10.0, 5.0, 10.0), p(10.0, 10.0, 5.0)];
|
||||||
says("blend_setback", a.blend_setback(Blend::Round, &three, &[1.0; 3], &corner, &[2.0], 0.1));
|
says("blend_setback", a.blend_setback(Blend::Round, &three, &[1.0; 3], &corner, &[2.0], 0.1));
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//! changes.
|
//! changes.
|
||||||
|
|
||||||
use crate::fonts::RichTextExt as _;
|
use crate::fonts::RichTextExt as _;
|
||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ fn spans(ui: &mut egui::Ui, spans: &[Span], size: f32, color: Color32) {
|
|||||||
for (label, url) in links {
|
for (label, url) in links {
|
||||||
// The text above already shows the label; the button says where it goes.
|
// The text above already shows the label; the button says where it goes.
|
||||||
let to = format!("↗ {}", host(url));
|
let to = format!("↗ {}", host(url));
|
||||||
ui.hyperlink_to(RichText::new(to).size(size - (t.font_button - t.font_body)), url).on_hover_text(format!("{label}: {url}"));
|
ui.hyperlink_to(RichText::new(to).size(size - (t.font_button - t.font_body)), url).backing_on_hover_text(format!("{label}: {url}"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -94,17 +95,21 @@ pub fn show(ui: &mut egui::Ui, slot: usize, src: &str) {
|
|||||||
ui.add(egui::Label::new(RichText::new(markdown::plain(s)).size(size).semibold().color(t.text)).selectable(true).wrap());
|
ui.add(egui::Label::new(RichText::new(markdown::plain(s)).size(size).semibold().color(t.text)).selectable(true).wrap());
|
||||||
}
|
}
|
||||||
Block::Code { text, .. } => {
|
Block::Code { text, .. } => {
|
||||||
egui::Frame::NONE.fill(t.field).corner_radius(t.radius_sm).inner_margin(Margin::same(6)).show(ui, |ui| {
|
crate::ui_kit::Container::show_backing(
|
||||||
ui.set_width(ui.available_width());
|
ui,
|
||||||
ui.add(
|
egui::Frame::NONE.fill(t.field).corner_radius(t.radius_sm).inner_margin(Margin::same(6)),
|
||||||
egui::Label::new(RichText::new(text).monospace().size(size - (t.font_button - t.font_body)).color(t.text))
|
|ui| {
|
||||||
.selectable(true)
|
ui.set_width(ui.available_width());
|
||||||
.wrap(),
|
ui.add(
|
||||||
);
|
egui::Label::new(RichText::new(text).monospace().size(size - (t.font_button - t.font_body)).color(t.text))
|
||||||
if crate::ui_kit::button(ui, tr("ui.agent_chat_copy"), crate::ui_kit::Button::Small).clicked() {
|
.selectable(true)
|
||||||
ui.ctx().copy_text(text.clone());
|
.wrap(),
|
||||||
}
|
);
|
||||||
});
|
if crate::ui_kit::button(ui, tr("ui.agent_chat_copy"), crate::ui_kit::Button::Small).clicked() {
|
||||||
|
ui.ctx().copy_text(text.clone());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Block::List { ordered, start, items } => {
|
Block::List { ordered, start, items } => {
|
||||||
for (i, item) in items.iter().enumerate() {
|
for (i, item) in items.iter().enumerate() {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
//! width and fold are preferences, so they come back next time.
|
//! width and fold are preferences, so they come back next time.
|
||||||
|
|
||||||
use crate::fonts::RichTextExt as _;
|
use crate::fonts::RichTextExt as _;
|
||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
use crate::ui_kit::{Container, Edge, Surface};
|
use crate::ui_kit::{Container, Edge, Surface};
|
||||||
use egui::{Align, Align2, Color32, Id, Layout, Rect, RichText, Sense, Stroke, pos2, vec2};
|
use egui::{Align, Align2, Color32, Id, Layout, Rect, RichText, Sense, Stroke, pos2, vec2};
|
||||||
|
|
||||||
@@ -379,7 +380,7 @@ fn context_chips(ui: &mut egui::Ui, context: &Context, removable: bool) -> Optio
|
|||||||
})
|
})
|
||||||
.response;
|
.response;
|
||||||
if !item.detail.is_empty() {
|
if !item.detail.is_empty() {
|
||||||
chip.on_hover_text(&item.detail);
|
chip.backing_on_hover_text(&item.detail);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -425,7 +426,7 @@ fn picture_strip(ui: &mut egui::Ui, n: usize, pictures: &[Picture]) -> Option<us
|
|||||||
let thumb = ui.add(egui::Image::new((texture.id(), size)).corner_radius(t.radius_sm).sense(Sense::click()));
|
let thumb = ui.add(egui::Image::new((texture.id(), size)).corner_radius(t.radius_sm).sense(Sense::click()));
|
||||||
crate::scenario::publish_handle(&format!("agent_chat:picture:{n}:{i}"), thumb.rect.center());
|
crate::scenario::publish_handle(&format!("agent_chat:picture:{n}:{i}"), thumb.rect.center());
|
||||||
let hover_max = (ui.ctx().content_rect().size() * 0.5).min(vec2(PREVIEW_MAX.0, PREVIEW_MAX.1));
|
let hover_max = (ui.ctx().content_rect().size() * 0.5).min(vec2(PREVIEW_MAX.0, PREVIEW_MAX.1));
|
||||||
let thumb = thumb.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_ui(|ui| {
|
let thumb = thumb.on_hover_cursor(egui::CursorIcon::PointingHand).backing_on_hover_ui(|ui| {
|
||||||
ui.add(egui::Image::new((texture.id(), fit(texture.size_vec2(), hover_max))).corner_radius(t.radius_sm));
|
ui.add(egui::Image::new((texture.id(), fit(texture.size_vec2(), hover_max))).corner_radius(t.radius_sm));
|
||||||
if !picture.note.is_empty() {
|
if !picture.note.is_empty() {
|
||||||
ui.label(&picture.note);
|
ui.label(&picture.note);
|
||||||
@@ -583,7 +584,8 @@ fn window(app: &mut SolveApp, ctx: &egui::Context) {
|
|||||||
}
|
}
|
||||||
let t = Tokens::get();
|
let t = Tokens::get();
|
||||||
let frame = Container::new(ui.ctx(), Surface::Panel).edge_frame(t.dialog_body);
|
let frame = Container::new(ui.ctx(), Surface::Panel).edge_frame(t.dialog_body);
|
||||||
let r = Container::new(ui.ctx(), Surface::Viewport).central().frame(frame).show(ui, |ui| ui_kit::scope(ui, |ui| body(app, ui, false)));
|
let panel = Container::new(ui.ctx(), Surface::Viewport).central();
|
||||||
|
let r = Container::show_central(ui, panel, frame, |ui| ui_kit::scope(ui, |ui| body(app, ui, false)));
|
||||||
// The picture viewer opens over this window, where the click was, not over the main one.
|
// The picture viewer opens over this window, where the click was, not over the main one.
|
||||||
gallery_window(app, ui.ctx());
|
gallery_window(app, ui.ctx());
|
||||||
if class == egui::ViewportClass::EmbeddedWindow {
|
if class == egui::ViewportClass::EmbeddedWindow {
|
||||||
@@ -672,7 +674,7 @@ fn glyph_button(ui: &mut egui::Ui, kind: &str, tip: &str) -> egui::Response {
|
|||||||
}
|
}
|
||||||
crate::scenario::publish_rect(&format!("agent_chat:{kind}"), r);
|
crate::scenario::publish_rect(&format!("agent_chat:{kind}"), r);
|
||||||
crate::scenario::publish_handle(&format!("agent_chat:{kind}"), r.center());
|
crate::scenario::publish_handle(&format!("agent_chat:{kind}"), r.center());
|
||||||
resp.on_hover_text(tip)
|
resp.backing_on_hover_text(tip)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn state_color(state: State) -> Color32 {
|
fn state_color(state: State) -> Color32 {
|
||||||
@@ -715,7 +717,7 @@ fn strip(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
t.primary_border(t.border),
|
t.primary_border(t.border),
|
||||||
);
|
);
|
||||||
let tip = format!("{} · {}", tip(app), state.label());
|
let tip = format!("{} · {}", tip(app), state.label());
|
||||||
if resp.on_hover_text(tip).clicked() {
|
if resp.backing_on_hover_text(tip).clicked() {
|
||||||
app.preferences.agent_chat_minimized = false;
|
app.preferences.agent_chat_minimized = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -967,7 +969,7 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.inner;
|
.inner;
|
||||||
let permissions = permissions.on_hover_text(mode.label());
|
let permissions = permissions.backing_on_hover_text(mode.label());
|
||||||
crate::scenario::publish_rect("agent_chat:permissions", permissions.rect);
|
crate::scenario::publish_rect("agent_chat:permissions", permissions.rect);
|
||||||
crate::scenario::publish_handle("agent_chat:permissions", permissions.rect.center());
|
crate::scenario::publish_handle("agent_chat:permissions", permissions.rect.center());
|
||||||
// A message waits for files still being read.
|
// A message waits for files still being read.
|
||||||
@@ -1004,8 +1006,8 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
let attach = ui
|
let attach = ui
|
||||||
.add_enabled_ui(images, |ui| ui_kit::checkbox(ui, tr("ui.agent_chat_attach_view"), &mut app.agent_chat.attach_view))
|
.add_enabled_ui(images, |ui| ui_kit::checkbox(ui, tr("ui.agent_chat_attach_view"), &mut app.agent_chat.attach_view))
|
||||||
.inner
|
.inner
|
||||||
.on_hover_text(tr("ui.agent_chat_attach_view_tip"))
|
.backing_on_hover_text(tr("ui.agent_chat_attach_view_tip"))
|
||||||
.on_disabled_hover_text(tr("ui.agent_chat_no_images"));
|
.backing_on_disabled_hover_text(tr("ui.agent_chat_no_images"));
|
||||||
crate::scenario::publish_handle("agent_chat:attach", attach.rect.center());
|
crate::scenario::publish_handle("agent_chat:attach", attach.rect.center());
|
||||||
// What is selected in the model goes with the message: text for the agent,
|
// What is selected in the model goes with the message: text for the agent,
|
||||||
// chips in the conversation.
|
// chips in the conversation.
|
||||||
@@ -1016,11 +1018,11 @@ fn desktop_body(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
tr("ui.agent_chat_attach_selection").to_string()
|
tr("ui.agent_chat_attach_selection").to_string()
|
||||||
};
|
};
|
||||||
let pick = ui_kit::checkbox(ui, &label, &mut app.agent_chat.attach_selection)
|
let pick = ui_kit::checkbox(ui, &label, &mut app.agent_chat.attach_selection)
|
||||||
.on_hover_text(tr("ui.agent_chat_attach_selection_tip"));
|
.backing_on_hover_text(tr("ui.agent_chat_attach_selection_tip"));
|
||||||
crate::scenario::publish_handle("agent_chat:attach_selection", pick.rect.center());
|
crate::scenario::publish_handle("agent_chat:attach_selection", pick.rect.center());
|
||||||
// Choose faces, edges, features and bodies on purpose, then OK.
|
// Choose faces, edges, features and bodies on purpose, then OK.
|
||||||
let picker = ui_kit::button(ui, tr("ui.agent_chat_pick_context"), ui_kit::Button::Enabled(app.dialog.is_none()))
|
let picker = ui_kit::button(ui, tr("ui.agent_chat_pick_context"), ui_kit::Button::Enabled(app.dialog.is_none()))
|
||||||
.on_hover_text(tr("ui.agent_chat_pick_context_tip"));
|
.backing_on_hover_text(tr("ui.agent_chat_pick_context_tip"));
|
||||||
crate::scenario::publish_handle("agent_chat:pick_context", picker.rect.center());
|
crate::scenario::publish_handle("agent_chat:pick_context", picker.rect.center());
|
||||||
pick_clicked = picker.clicked();
|
pick_clicked = picker.clicked();
|
||||||
if let Some(turn) = &app.agent_chat.turn {
|
if let Some(turn) = &app.agent_chat.turn {
|
||||||
@@ -1101,7 +1103,7 @@ fn switcher(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
ui_kit::dropdown_menu(ui, "sc_agent_switch", &label, |ui| {
|
ui_kit::dropdown_menu(ui, "sc_agent_switch", &label, |ui| {
|
||||||
for e in &all {
|
for e in &all {
|
||||||
let sel = current.as_ref().is_some_and(|c| c.id == e.id);
|
let sel = current.as_ref().is_some_and(|c| c.id == e.id);
|
||||||
let r = ui_kit::list_picker(ui, &e.id, &e.name, sel).on_hover_text(format!("{} {}", e.detail(), e.login));
|
let r = ui_kit::list_picker(ui, &e.id, &e.name, sel).backing_on_hover_text(format!("{} {}", e.detail(), e.login));
|
||||||
crate::scenario::publish_handle(&format!("agent_chat:agent:{}", e.id), r.rect.center());
|
crate::scenario::publish_handle(&format!("agent_chat:agent:{}", e.id), r.rect.center());
|
||||||
if r.clicked() {
|
if r.clicked() {
|
||||||
picked = Some(e.id.clone());
|
picked = Some(e.id.clone());
|
||||||
@@ -1119,7 +1121,7 @@ fn switcher(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
.inner;
|
.inner;
|
||||||
crate::scenario::publish_rect("agent_chat:switcher", combo.rect);
|
crate::scenario::publish_rect("agent_chat:switcher", combo.rect);
|
||||||
crate::scenario::publish_handle("agent_chat:switcher", combo.rect.center());
|
crate::scenario::publish_handle("agent_chat:switcher", combo.rect.center());
|
||||||
combo.on_hover_text(tr("ui.ai_switch_tip"));
|
combo.backing_on_hover_text(tr("ui.ai_switch_tip"));
|
||||||
if add {
|
if add {
|
||||||
super::settings::open_add(app);
|
super::settings::open_add(app);
|
||||||
}
|
}
|
||||||
@@ -1148,7 +1150,7 @@ fn switcher(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
.inner;
|
.inner;
|
||||||
crate::scenario::publish_rect("agent_chat:mode", r.rect);
|
crate::scenario::publish_rect("agent_chat:mode", r.rect);
|
||||||
crate::scenario::publish_handle("agent_chat:mode", r.rect.center());
|
crate::scenario::publish_handle("agent_chat:mode", r.rect.center());
|
||||||
r.on_hover_text(tr("ui.ai_agent_mode_tip"));
|
r.backing_on_hover_text(tr("ui.ai_agent_mode_tip"));
|
||||||
if let Some(m) = pick {
|
if let Some(m) = pick {
|
||||||
set_mode(app, &m);
|
set_mode(app, &m);
|
||||||
}
|
}
|
||||||
@@ -1187,7 +1189,7 @@ fn switcher(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
.inner;
|
.inner;
|
||||||
picker.response.on_hover_text(tr("ui.agent_chat_model_tip"));
|
picker.response.backing_on_hover_text(tr("ui.agent_chat_model_tip"));
|
||||||
match picker.inner {
|
match picker.inner {
|
||||||
Some(ui_kit::Picked::Listed(m)) => set_model(app, &m),
|
Some(ui_kit::Picked::Listed(m)) => set_model(app, &m),
|
||||||
Some(ui_kit::Picked::Custom(m)) => {
|
Some(ui_kit::Picked::Custom(m)) => {
|
||||||
@@ -1205,7 +1207,7 @@ fn switcher(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
}
|
}
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
let b = if app.agent_chat.state().running() {
|
let b = if app.agent_chat.state().running() {
|
||||||
ui_kit::button(ui, tr("ui.agent_chat_stop"), ui_kit::Button::Normal).on_hover_text(tr("ui.agent_chat_stop_tip"))
|
ui_kit::button(ui, tr("ui.agent_chat_stop"), ui_kit::Button::Normal).backing_on_hover_text(tr("ui.agent_chat_stop_tip"))
|
||||||
} else {
|
} else {
|
||||||
let label = if app.agent_chat.state() == State::Failed { "ui.agent_chat_retry" } else { "ui.agent_chat_connect" };
|
let label = if app.agent_chat.state() == State::Failed { "ui.agent_chat_retry" } else { "ui.agent_chat_connect" };
|
||||||
ui_kit::button(ui, tr(label), ui_kit::Button::Normal)
|
ui_kit::button(ui, tr(label), ui_kit::Button::Normal)
|
||||||
@@ -1372,11 +1374,11 @@ fn config_select(app: &mut SolveApp, ui: &mut egui::Ui, option: &acp::ConfigOpti
|
|||||||
if let Some(group) = group
|
if let Some(group) = group
|
||||||
&& previous_group != Some(group)
|
&& previous_group != Some(group)
|
||||||
{
|
{
|
||||||
ui.label(RichText::new(compact_text(group, 32)).color(t.text_dim).size(t.font_small)).on_hover_text(group);
|
ui.label(RichText::new(compact_text(group, 32)).color(t.text_dim).size(t.font_small)).backing_on_hover_text(group);
|
||||||
}
|
}
|
||||||
previous_group = group;
|
previous_group = group;
|
||||||
let row = ui_kit::list_picker(ui, &choice.value, &compact_text(&choice.name, 32), choice.value == option.current_value);
|
let row = ui_kit::list_picker(ui, &choice.value, &compact_text(&choice.name, 32), choice.value == option.current_value);
|
||||||
let row = row.on_hover_text(
|
let row = row.backing_on_hover_text(
|
||||||
[choice.name.as_str(), choice.description.as_str()]
|
[choice.name.as_str(), choice.description.as_str()]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|part| !part.is_empty())
|
.filter(|part| !part.is_empty())
|
||||||
@@ -1400,7 +1402,7 @@ fn config_select(app: &mut SolveApp, ui: &mut egui::Ui, option: &acp::ConfigOpti
|
|||||||
response
|
response
|
||||||
};
|
};
|
||||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, enabled, format!("{name}: {selected}")));
|
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, enabled, format!("{name}: {selected}")));
|
||||||
response.on_hover_text(tip);
|
response.backing_on_hover_text(tip);
|
||||||
match picked {
|
match picked {
|
||||||
Some(ui_kit::Picked::Listed(value)) => super::set_config_option(app, &option.id, &value),
|
Some(ui_kit::Picked::Listed(value)) => super::set_config_option(app, &option.id, &value),
|
||||||
Some(ui_kit::Picked::Custom(value)) => {
|
Some(ui_kit::Picked::Custom(value)) => {
|
||||||
@@ -1556,7 +1558,7 @@ fn agent_list(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
}
|
}
|
||||||
for e in &all {
|
for e in &all {
|
||||||
let selected = current.as_ref().is_some_and(|c| c.id == e.id);
|
let selected = current.as_ref().is_some_and(|c| c.id == e.id);
|
||||||
let resp = ui_kit::list_picker(ui, &e.id, &e.name, selected).on_hover_text(format!("{} {}", e.detail(), e.login));
|
let resp = ui_kit::list_picker(ui, &e.id, &e.name, selected).backing_on_hover_text(format!("{} {}", e.detail(), e.login));
|
||||||
crate::scenario::publish_handle(&format!("agent_chat:pick:{}", e.id), resp.rect.center());
|
crate::scenario::publish_handle(&format!("agent_chat:pick:{}", e.id), resp.rect.center());
|
||||||
if resp.clicked() {
|
if resp.clicked() {
|
||||||
app.agent_chat.agent_id = Some(e.id.clone());
|
app.agent_chat.agent_id = Some(e.id.clone());
|
||||||
@@ -1575,7 +1577,7 @@ fn agent_list(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
for m in missing {
|
for m in missing {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui_kit::text(ui, m.name, ui_kit::Text::Inline);
|
ui_kit::text(ui, m.name, ui_kit::Text::Inline);
|
||||||
if ui_kit::button(ui, tr("ui.agent_chat_copy"), ui_kit::Button::Small).on_hover_text(m.install).clicked() {
|
if ui_kit::button(ui, tr("ui.agent_chat_copy"), ui_kit::Button::Small).backing_on_hover_text(m.install).clicked() {
|
||||||
ui.ctx().copy_text(m.install.to_string());
|
ui.ctx().copy_text(m.install.to_string());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1812,7 +1814,7 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
ui_kit::assistant_actions(ui, |ui| {
|
ui_kit::assistant_actions(ui, |ui| {
|
||||||
let allow = ui_kit::primary_button(ui, tr("ui.agent_chat_allow_once"));
|
let allow = ui_kit::primary_button(ui, tr("ui.agent_chat_allow_once"));
|
||||||
let always = ui_kit::button(ui, tr("ui.ai_always_allow"), ui_kit::Button::Normal)
|
let always = ui_kit::button(ui, tr("ui.ai_always_allow"), ui_kit::Button::Normal)
|
||||||
.on_hover_text(trf("ui.ai_always_allow_tip", &[("tool", tool)]));
|
.backing_on_hover_text(trf("ui.ai_always_allow_tip", &[("tool", tool)]));
|
||||||
let deny = ui
|
let deny = ui
|
||||||
.with_layout(Layout::left_to_right(Align::Center), |ui| {
|
.with_layout(Layout::left_to_right(Align::Center), |ui| {
|
||||||
ui_kit::button(ui, tr("ui.agent_chat_deny"), ui_kit::Button::Normal)
|
ui_kit::button(ui, tr("ui.agent_chat_deny"), ui_kit::Button::Normal)
|
||||||
@@ -1842,7 +1844,8 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
if *undone {
|
if *undone {
|
||||||
ui.label(RichText::new(tr("ui.agent_chat_undone")).color(t.text_dim).size(crate::theme::Tokens::get().font_caption));
|
ui.label(RichText::new(tr("ui.agent_chat_undone")).color(t.text_dim).size(crate::theme::Tokens::get().font_caption));
|
||||||
} else if Arc::ptr_eq(doc, ¤t_doc) {
|
} else if Arc::ptr_eq(doc, ¤t_doc) {
|
||||||
let b = ui_kit::button(ui, tr("ui.agent_chat_undo_changes"), ui_kit::Button::Normal).on_hover_text(tr("ui.ai_undo_tip"));
|
let b =
|
||||||
|
ui_kit::button(ui, tr("ui.agent_chat_undo_changes"), ui_kit::Button::Normal).backing_on_hover_text(tr("ui.ai_undo_tip"));
|
||||||
crate::scenario::publish_handle("agent_chat:undo", b.rect.center());
|
crate::scenario::publish_handle("agent_chat:undo", b.rect.center());
|
||||||
if b.clicked() {
|
if b.clicked() {
|
||||||
undo = Some(n);
|
undo = Some(n);
|
||||||
@@ -1867,61 +1870,62 @@ fn conversation(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
let mut edit_buf = editing.as_ref().map(|(_, b)| b.clone());
|
let mut edit_buf = editing.as_ref().map(|(_, b)| b.clone());
|
||||||
for (n, p) in app.agent_chat.pending.iter().enumerate() {
|
for (n, p) in app.agent_chat.pending.iter().enumerate() {
|
||||||
ui.add_space(6.0);
|
ui.add_space(6.0);
|
||||||
Container::new(ui.ctx(), Surface::Panel)
|
let frame = Container::new(ui.ctx(), Surface::Panel)
|
||||||
.edge_frame(t.panel)
|
.edge_frame(t.panel)
|
||||||
.stroke(Stroke::new(1.0, t.accent))
|
.stroke(Stroke::new(1.0, t.accent))
|
||||||
.corner_radius(t.control_radius)
|
.corner_radius(t.control_radius)
|
||||||
.inner_margin(egui::Margin::same(6))
|
.inner_margin(egui::Margin::same(6));
|
||||||
.show(ui, |ui| {
|
Container::show_backing(ui, frame, |ui| {
|
||||||
ui.set_width(ui.available_width());
|
ui.set_width(ui.available_width());
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
let tag = if stopped && !busy { "ui.agent_chat_queued_stopped" } else { "ui.agent_chat_queued" };
|
let tag = if stopped && !busy { "ui.agent_chat_queued_stopped" } else { "ui.agent_chat_queued" };
|
||||||
ui.label(RichText::new(tr(tag)).color(t.accent).semibold().size(crate::theme::Tokens::get().font_small));
|
ui.label(RichText::new(tr(tag)).color(t.accent).semibold().size(crate::theme::Tokens::get().font_small));
|
||||||
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
|
||||||
let x = ui_kit::button(ui, "×", ui_kit::Button::Small).on_hover_text(tr("ui.agent_chat_remove"));
|
let x = ui_kit::button(ui, "×", ui_kit::Button::Small).backing_on_hover_text(tr("ui.agent_chat_remove"));
|
||||||
crate::scenario::publish_handle(&format!("agent_chat:pending_remove:{n}"), x.rect.center());
|
crate::scenario::publish_handle(&format!("agent_chat:pending_remove:{n}"), x.rect.center());
|
||||||
if x.clicked() {
|
if x.clicked() {
|
||||||
remove = Some(n);
|
remove = Some(n);
|
||||||
|
}
|
||||||
|
if editing.as_ref().is_none_or(|(i, _)| *i != n) {
|
||||||
|
let e = ui_kit::button(ui, tr("ui.agent_chat_edit"), ui_kit::Button::Small);
|
||||||
|
crate::scenario::publish_handle(&format!("agent_chat:pending_edit:{n}"), e.rect.center());
|
||||||
|
if e.clicked() {
|
||||||
|
start_edit = Some((n, p.text.clone()));
|
||||||
}
|
}
|
||||||
if editing.as_ref().is_none_or(|(i, _)| *i != n) {
|
}
|
||||||
let e = ui_kit::button(ui, tr("ui.agent_chat_edit"), ui_kit::Button::Small);
|
});
|
||||||
crate::scenario::publish_handle(&format!("agent_chat:pending_edit:{n}"), e.rect.center());
|
});
|
||||||
if e.clicked() {
|
match (&editing, edit_buf.as_mut()) {
|
||||||
start_edit = Some((n, p.text.clone()));
|
(Some((i, _)), Some(buf)) if *i == n => {
|
||||||
}
|
let te = ui_kit::message_field(ui, Id::new(("sc_agent_pending_input", n)), buf, "");
|
||||||
|
crate::scenario::publish_rect("agent_chat:pending_input", te.rect);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui_kit::button(ui, tr("ui.agent_chat_save"), ui_kit::Button::Normal).clicked() {
|
||||||
|
edit_done = Some((n, Some(buf.clone())));
|
||||||
|
}
|
||||||
|
if ui_kit::button(ui, tr("ui.agent_chat_cancel"), ui_kit::Button::Normal).clicked() {
|
||||||
|
edit_done = Some((n, None));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
|
||||||
match (&editing, edit_buf.as_mut()) {
|
|
||||||
(Some((i, _)), Some(buf)) if *i == n => {
|
|
||||||
let te = ui_kit::message_field(ui, Id::new(("sc_agent_pending_input", n)), buf, "");
|
|
||||||
crate::scenario::publish_rect("agent_chat:pending_input", te.rect);
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
if ui_kit::button(ui, tr("ui.agent_chat_save"), ui_kit::Button::Normal).clicked() {
|
|
||||||
edit_done = Some((n, Some(buf.clone())));
|
|
||||||
}
|
|
||||||
if ui_kit::button(ui, tr("ui.agent_chat_cancel"), ui_kit::Button::Normal).clicked() {
|
|
||||||
edit_done = Some((n, None));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
if !p.text.is_empty() {
|
|
||||||
ui.add(egui::Label::new(RichText::new(&p.text).color(t.text)).selectable(true).wrap());
|
|
||||||
}
|
|
||||||
if p.image {
|
|
||||||
ui.label(RichText::new(tr("ui.agent_chat_view_attached")).color(t.text_dim).size(crate::theme::Tokens::get().font_small));
|
|
||||||
}
|
|
||||||
context_chips(ui, &p.context, false);
|
|
||||||
files_line(ui, p.files.iter().map(|f| f.name.as_str()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
_ => {
|
||||||
|
if !p.text.is_empty() {
|
||||||
|
ui.add(egui::Label::new(RichText::new(&p.text).color(t.text)).selectable(true).wrap());
|
||||||
|
}
|
||||||
|
if p.image {
|
||||||
|
ui.label(RichText::new(tr("ui.agent_chat_view_attached")).color(t.text_dim).size(crate::theme::Tokens::get().font_small));
|
||||||
|
}
|
||||||
|
context_chips(ui, &p.context, false);
|
||||||
|
files_line(ui, p.files.iter().map(|f| f.name.as_str()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if !app.agent_chat.pending.is_empty() && editing.is_none() {
|
if !app.agent_chat.pending.is_empty() && editing.is_none() {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if busy {
|
if busy {
|
||||||
let b = ui_kit::button(ui, tr("ui.agent_chat_send_now"), ui_kit::Button::Normal).on_hover_text(tr("ui.agent_chat_send_now_tip"));
|
let b =
|
||||||
|
ui_kit::button(ui, tr("ui.agent_chat_send_now"), ui_kit::Button::Normal).backing_on_hover_text(tr("ui.agent_chat_send_now_tip"));
|
||||||
crate::scenario::publish_handle("agent_chat:send_now", b.rect.center());
|
crate::scenario::publish_handle("agent_chat:send_now", b.rect.center());
|
||||||
send_now_clicked = b.clicked();
|
send_now_clicked = b.clicked();
|
||||||
} else {
|
} else {
|
||||||
@@ -2054,34 +2058,38 @@ pub(super) fn remove_tip(f: &attach::Attachment) -> &'static str {
|
|||||||
fn loading_chip(ui: &mut egui::Ui, name: &str, importing: bool, i: usize, w: f32) -> bool {
|
fn loading_chip(ui: &mut egui::Ui, name: &str, importing: bool, i: usize, w: f32) -> bool {
|
||||||
let t = Tokens::get();
|
let t = Tokens::get();
|
||||||
let mut clicked = false;
|
let mut clicked = false;
|
||||||
let r = egui::Frame::NONE
|
let r = crate::ui_kit::Container::show_backing(
|
||||||
.fill(t.toolbar)
|
ui,
|
||||||
.stroke(t.control_border_stroke(t.border))
|
egui::Frame::NONE
|
||||||
.corner_radius(t.control_radius)
|
.fill(t.toolbar)
|
||||||
.inner_margin(egui::Margin::symmetric(4, 3))
|
.stroke(t.control_border_stroke(t.border))
|
||||||
.show(ui, |ui| {
|
.corner_radius(t.control_radius)
|
||||||
|
.inner_margin(egui::Margin::symmetric(4, 3)),
|
||||||
|
|ui| {
|
||||||
let inner = (w - 10.0).max(40.0);
|
let inner = (w - 10.0).max(40.0);
|
||||||
ui.set_width(inner);
|
ui.set_width(inner);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.spacing_mut().item_spacing.x = 4.0;
|
ui.spacing_mut().item_spacing.x = 4.0;
|
||||||
let doing = tr(if importing { "ui.agent_chat_file_importing" } else { "ui.agent_chat_file_loading" });
|
let doing = tr(if importing { "ui.agent_chat_file_importing" } else { "ui.agent_chat_file_loading" });
|
||||||
let mark = ui.add(egui::Spinner::new().size(t.form_icon_size)).on_hover_text(doing).rect.width();
|
let mark = ui.add(egui::Spinner::new().size(t.form_icon_size)).backing_on_hover_text(doing).rect.width();
|
||||||
let name_w = (inner - mark - 18.0 - 8.0).max(8.0);
|
let name_w = (inner - mark - 18.0 - 8.0).max(8.0);
|
||||||
ui.allocate_ui(vec2(name_w, 20.0), |ui| {
|
ui.allocate_ui(vec2(name_w, 20.0), |ui| {
|
||||||
ui.set_width(name_w);
|
ui.set_width(name_w);
|
||||||
ui.add(egui::Label::new(RichText::new(name).size(t.font_small).color(t.text_dim)).truncate())
|
ui.add(egui::Label::new(RichText::new(name).size(t.font_small).color(t.text_dim)).truncate())
|
||||||
})
|
})
|
||||||
.inner
|
.inner
|
||||||
.on_hover_text(name);
|
.backing_on_hover_text(name);
|
||||||
let (r, x) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
|
let (r, x) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
|
||||||
let c = r.center();
|
let c = r.center();
|
||||||
let s = Stroke::new(1.3, if x.hovered() { t.text } else { t.text_dim });
|
let s = Stroke::new(1.3, if x.hovered() { t.text } else { t.text_dim });
|
||||||
ui.painter().line_segment([c + vec2(-3.5, -3.5), c + vec2(3.5, 3.5)], s);
|
ui.painter().line_segment([c + vec2(-3.5, -3.5), c + vec2(3.5, 3.5)], s);
|
||||||
ui.painter().line_segment([c + vec2(-3.5, 3.5), c + vec2(3.5, -3.5)], s);
|
ui.painter().line_segment([c + vec2(-3.5, 3.5), c + vec2(3.5, -3.5)], s);
|
||||||
crate::scenario::publish_handle(&format!("agent_chat:file_loading_remove:{i}"), c);
|
crate::scenario::publish_handle(&format!("agent_chat:file_loading_remove:{i}"), c);
|
||||||
clicked = x.on_hover_text(tr(if importing { "ui.agent_chat_remove_imported" } else { "ui.agent_chat_remove_file" })).clicked();
|
clicked =
|
||||||
|
x.backing_on_hover_text(tr(if importing { "ui.agent_chat_remove_imported" } else { "ui.agent_chat_remove_file" })).clicked();
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
crate::scenario::publish_handle(&format!("agent_chat:file_loading:{i}"), r.response.rect.center());
|
crate::scenario::publish_handle(&format!("agent_chat:file_loading:{i}"), r.response.rect.center());
|
||||||
clicked
|
clicked
|
||||||
}
|
}
|
||||||
@@ -2091,12 +2099,14 @@ fn loading_chip(ui: &mut egui::Ui, name: &str, importing: bool, i: usize, w: f32
|
|||||||
fn chip(ui: &mut egui::Ui, thumbs: &mut std::collections::HashMap<u64, egui::TextureHandle>, f: &attach::Attachment, i: usize, w: f32) -> bool {
|
fn chip(ui: &mut egui::Ui, thumbs: &mut std::collections::HashMap<u64, egui::TextureHandle>, f: &attach::Attachment, i: usize, w: f32) -> bool {
|
||||||
let t = Tokens::get();
|
let t = Tokens::get();
|
||||||
let mut clicked = false;
|
let mut clicked = false;
|
||||||
let r = egui::Frame::NONE
|
let r = crate::ui_kit::Container::show_backing(
|
||||||
.fill(t.toolbar)
|
ui,
|
||||||
.stroke(t.control_border_stroke(t.border))
|
egui::Frame::NONE
|
||||||
.corner_radius(t.control_radius)
|
.fill(t.toolbar)
|
||||||
.inner_margin(egui::Margin::symmetric(4, 3))
|
.stroke(t.control_border_stroke(t.border))
|
||||||
.show(ui, |ui| {
|
.corner_radius(t.control_radius)
|
||||||
|
.inner_margin(egui::Margin::symmetric(4, 3)),
|
||||||
|
|ui| {
|
||||||
let inner = (w - 10.0).max(40.0);
|
let inner = (w - 10.0).max(40.0);
|
||||||
ui.set_width(inner);
|
ui.set_width(inner);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
@@ -2130,16 +2140,17 @@ fn chip(ui: &mut egui::Ui, thumbs: &mut std::collections::HashMap<u64, egui::Tex
|
|||||||
ui.add(egui::Label::new(RichText::new(&f.name).size(t.font_small)).truncate())
|
ui.add(egui::Label::new(RichText::new(&f.name).size(t.font_small)).truncate())
|
||||||
})
|
})
|
||||||
.inner
|
.inner
|
||||||
.on_hover_text(&f.name);
|
.backing_on_hover_text(&f.name);
|
||||||
let (r, x) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
|
let (r, x) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click());
|
||||||
let c = r.center();
|
let c = r.center();
|
||||||
let s = Stroke::new(1.3, if x.hovered() { t.text } else { t.text_dim });
|
let s = Stroke::new(1.3, if x.hovered() { t.text } else { t.text_dim });
|
||||||
ui.painter().line_segment([c + vec2(-3.5, -3.5), c + vec2(3.5, 3.5)], s);
|
ui.painter().line_segment([c + vec2(-3.5, -3.5), c + vec2(3.5, 3.5)], s);
|
||||||
ui.painter().line_segment([c + vec2(-3.5, 3.5), c + vec2(3.5, -3.5)], s);
|
ui.painter().line_segment([c + vec2(-3.5, 3.5), c + vec2(3.5, -3.5)], s);
|
||||||
crate::scenario::publish_handle(&format!("agent_chat:file_remove:{i}"), c);
|
crate::scenario::publish_handle(&format!("agent_chat:file_remove:{i}"), c);
|
||||||
clicked = x.on_hover_text(remove_tip(f)).clicked();
|
clicked = x.backing_on_hover_text(remove_tip(f)).clicked();
|
||||||
});
|
});
|
||||||
});
|
},
|
||||||
|
);
|
||||||
crate::scenario::publish_target(&format!("agent_chat:file:{i}"), r.response.rect);
|
crate::scenario::publish_target(&format!("agent_chat:file:{i}"), r.response.rect);
|
||||||
clicked
|
clicked
|
||||||
}
|
}
|
||||||
@@ -2164,7 +2175,7 @@ fn add_menu(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
crate::scenario::publish_handle("agent_chat:add", r.response.rect.center());
|
crate::scenario::publish_handle("agent_chat:add", r.response.rect.center());
|
||||||
r.response.on_hover_text(tr("ui.agent_chat_add"));
|
r.response.backing_on_hover_text(tr("ui.agent_chat_add"));
|
||||||
if choose {
|
if choose {
|
||||||
app.pick_file(tr("ui.agent_chat_attachable"), &attach::extensions(), |app, path| {
|
app.pick_file(tr("ui.agent_chat_attachable"), &attach::extensions(), |app, path| {
|
||||||
if let Some(path) = path
|
if let Some(path) = path
|
||||||
@@ -2232,7 +2243,7 @@ fn tool_card(ui: &mut egui::Ui, n: usize, call: &ToolCall) {
|
|||||||
Some(words) => RichText::new(words).size(t.font_caption).color(t.text),
|
Some(words) => RichText::new(words).size(t.font_caption).color(t.text),
|
||||||
None => RichText::new(summary::plain_title(call.title)).monospace().size(t.font_caption).color(t.text),
|
None => RichText::new(summary::plain_title(call.title)).monospace().size(t.font_caption).color(t.text),
|
||||||
};
|
};
|
||||||
ui.add(egui::Label::new(text).truncate().selectable(false)).on_hover_text(call.title);
|
ui.add(egui::Label::new(text).truncate().selectable(false)).backing_on_hover_text(call.title);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -2242,7 +2253,7 @@ fn tool_card(ui: &mut egui::Ui, n: usize, call: &ToolCall) {
|
|||||||
state_.toggle(ui);
|
state_.toggle(ui);
|
||||||
}
|
}
|
||||||
if details {
|
if details {
|
||||||
click.on_hover_text(tr("ui.ai_tool_unfold"));
|
click.backing_on_hover_text(tr("ui.ai_tool_unfold"));
|
||||||
}
|
}
|
||||||
state_.show_body_unindented(ui, |ui| {
|
state_.show_body_unindented(ui, |ui| {
|
||||||
ui.indent(id.with("detail"), |ui| {
|
ui.indent(id.with("detail"), |ui| {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use super::agents::{self, AgentConfig, Kind, Mode, Protocol};
|
|||||||
use super::backend::TestError;
|
use super::backend::TestError;
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::ui_kit;
|
use crate::ui_kit;
|
||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||||
pub enum AddKind {
|
pub enum AddKind {
|
||||||
@@ -405,7 +406,7 @@ fn start_test(app: &SolveApp, e: &agents::Entry, key: Option<String>) -> Test {
|
|||||||
/// An endpoint's request timeout field: seconds, 30 s to an hour.
|
/// 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 {
|
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, "")
|
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"));
|
.backing_on_hover_text(tr("ui.ai_request_timeout_hint"));
|
||||||
r.widget_info(|| {
|
r.widget_info(|| {
|
||||||
let mut info = egui::WidgetInfo::drag_value(ui.is_enabled(), *secs as f64);
|
let mut info = egui::WidgetInfo::drag_value(ui.is_enabled(), *secs as f64);
|
||||||
info.label = Some(tr("prefs.assistant_seconds_aria").to_owned());
|
info.label = Some(tr("prefs.assistant_seconds_aria").to_owned());
|
||||||
@@ -419,7 +420,7 @@ fn timeout_field(ui: &mut egui::Ui, secs: &mut u64, handle: &str) -> bool {
|
|||||||
/// The free-text reasoning effort field with its hover hint; true when the text 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, handle: &str) -> bool {
|
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)
|
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"));
|
.backing_on_hover_text(tr("ui.ai_reasoning_effort_hint"));
|
||||||
crate::scenario::publish_target(handle, response.rect);
|
crate::scenario::publish_target(handle, response.rect);
|
||||||
response.changed()
|
response.changed()
|
||||||
}
|
}
|
||||||
@@ -446,14 +447,14 @@ 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_heading(ui, tr("prefs.assistant"), tr("ai.prefs.intro"));
|
||||||
ui_kit::settings_section(ui, "assistant_availability", tr("ai.prefs.availability"), |ui| {
|
ui_kit::settings_section(ui, "assistant_availability", tr("ai.prefs.availability"), |ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
let response =
|
let response = ui_kit::checkbox(ui, tr("ui.ai_show_features"), &mut app.preferences.ai_features)
|
||||||
ui_kit::checkbox(ui, tr("ui.ai_show_features"), &mut app.preferences.ai_features).on_hover_text(tr("ui.ai_show_features_note"));
|
.backing_on_hover_text(tr("ui.ai_show_features_note"));
|
||||||
crate::scenario::publish_handle("prefs:ai_features", response.rect.center());
|
crate::scenario::publish_handle("prefs:ai_features", response.rect.center());
|
||||||
ui_kit::help_icon(ui, tr("ui.ai_show_features_note"));
|
ui_kit::help_icon(ui, tr("ui.ai_show_features_note"));
|
||||||
});
|
});
|
||||||
ui.add_enabled_ui(app.preferences.ai_features && !cfg!(target_arch = "wasm32"), |ui| {
|
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)
|
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"));
|
.backing_on_hover_text(tr("ui.ai_toolbar_button_note"));
|
||||||
crate::scenario::publish_handle("prefs:ai_toolbar_button", response.rect.center());
|
crate::scenario::publish_handle("prefs:ai_toolbar_button", response.rect.center());
|
||||||
});
|
});
|
||||||
if cfg!(target_arch = "wasm32") {
|
if cfg!(target_arch = "wasm32") {
|
||||||
@@ -487,7 +488,7 @@ fn connecting_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
("ai.prefs.open_at_start", "ai.prefs.open_at_start_note", "agent_chat_open_at_start", &mut app.preferences.agent_chat_open_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| {
|
ui.horizontal(|ui| {
|
||||||
let response = ui_kit::checkbox(ui, tr(label), value).on_hover_text(tr(help));
|
let response = ui_kit::checkbox(ui, tr(label), value).backing_on_hover_text(tr(help));
|
||||||
crate::scenario::publish_handle(&format!("prefs:{handle}"), response.rect.center());
|
crate::scenario::publish_handle(&format!("prefs:{handle}"), response.rect.center());
|
||||||
ui_kit::help_icon(ui, tr(help));
|
ui_kit::help_icon(ui, tr(help));
|
||||||
});
|
});
|
||||||
@@ -540,7 +541,8 @@ fn permissions_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
.collect();
|
.collect();
|
||||||
ui_kit::section_count(ui, "always_allowed", tr("ui.ai_always_allowed"), false, Some(remembered.len()), |ui| {
|
ui_kit::section_count(ui, "always_allowed", tr("ui.ai_always_allowed"), false, Some(remembered.len()), |ui| {
|
||||||
if remembered.is_empty() {
|
if remembered.is_empty() {
|
||||||
ui_kit::text(ui, tr("ai.prefs.no_saved_approvals"), ui_kit::Text::Hint("none")).on_hover_text(tr("ui.ai_always_allowed_none"));
|
ui_kit::text(ui, tr("ai.prefs.no_saved_approvals"), ui_kit::Text::Hint("none"))
|
||||||
|
.backing_on_hover_text(tr("ui.ai_always_allowed_none"));
|
||||||
}
|
}
|
||||||
let names: std::collections::HashMap<_, _> = agents::entries(app).into_iter().map(|entry| (entry.id, entry.name)).collect();
|
let names: std::collections::HashMap<_, _> = agents::entries(app).into_iter().map(|entry| (entry.id, entry.name)).collect();
|
||||||
for (agent, tools) in remembered {
|
for (agent, tools) in remembered {
|
||||||
@@ -605,7 +607,7 @@ fn panel_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
}
|
}
|
||||||
if limit != 0 {
|
if limit != 0 {
|
||||||
let response =
|
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"));
|
ui_kit::settings_number(ui, &mut p.agent_chat_turn_limit, 1..=200, 1.0).backing_on_hover_text(tr("ai.prefs.turn_limit_hint"));
|
||||||
response.widget_info(|| {
|
response.widget_info(|| {
|
||||||
let mut info = egui::WidgetInfo::drag_value(ui.is_enabled(), f64::from(p.agent_chat_turn_limit));
|
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.label = Some(tr("prefs.assistant_limit").to_owned());
|
||||||
@@ -625,7 +627,7 @@ fn pointer_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
ui,
|
ui,
|
||||||
|ui| {
|
|ui| {
|
||||||
let response = 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)"));
|
.backing_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);
|
crate::scenario::publish_target("prefs:agent_pointer_show", response.rect);
|
||||||
ui.add_enabled_ui(app.ui.agent_cursor.show, |ui| {
|
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;
|
let speed_label_width = ui_kit::ContentWidth::label(ui, tr("ai.prefs.pointer_speed")) + crate::theme::Tokens::get().space_4;
|
||||||
@@ -653,7 +655,7 @@ fn pointer_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
});
|
});
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
let response = ui_kit::checkbox(ui, tr("ai.prefs.follow_camera"), &mut app.ui.agent_cursor.follow_camera)
|
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"));
|
.backing_on_hover_text(tr("prefs.assistant_follow_help"));
|
||||||
crate::scenario::publish_target("prefs:agent_pointer_follow", response.rect);
|
crate::scenario::publish_target("prefs:agent_pointer_follow", response.rect);
|
||||||
ui_kit::help_icon(ui, tr("prefs.assistant_follow_help"));
|
ui_kit::help_icon(ui, tr("prefs.assistant_follow_help"));
|
||||||
});
|
});
|
||||||
@@ -719,13 +721,13 @@ fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
Kind::Api => "ui.ai_test_api_tip",
|
Kind::Api => "ui.ai_test_api_tip",
|
||||||
Kind::Anthropic => "ui.ai_test_anthropic_tip",
|
Kind::Anthropic => "ui.ai_test_anthropic_tip",
|
||||||
};
|
};
|
||||||
if test_button.on_hover_text(tr(tip)).clicked() {
|
if test_button.backing_on_hover_text(tr(tip)).clicked() {
|
||||||
test = Some(entry.clone());
|
test = Some(entry.clone());
|
||||||
}
|
}
|
||||||
if !entry.found {
|
if !entry.found {
|
||||||
let edit_button = ui_kit::button(ui, tr("ui.ai_edit"), ui_kit::Button::Small);
|
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());
|
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() {
|
if edit_button.backing_on_hover_text(tr("ui.ai_edit_tip")).clicked() {
|
||||||
edit = Some(entry.id.clone());
|
edit = Some(entry.id.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -748,7 +750,7 @@ fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
ui_kit::settings_short_note(ui, &detail).on_hover_text(&entry.id);
|
ui_kit::settings_short_note(ui, &detail).backing_on_hover_text(&entry.id);
|
||||||
// Wide enough for an endpoint's "Request timeout" or "Reasoning effort" too,
|
// Wide enough for an endpoint's "Request timeout" or "Reasoning effort" too,
|
||||||
// so all rows line up.
|
// so all rows line up.
|
||||||
let label_w = [tr("ui.ai_permissions"), tr("ui.ai_request_timeout"), tr("ui.ai_reasoning_effort")]
|
let label_w = [tr("ui.ai_permissions"), tr("ui.ai_request_timeout"), tr("ui.ai_reasoning_effort")]
|
||||||
@@ -819,7 +821,7 @@ fn agents_section(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
}
|
}
|
||||||
if app.preferences.agent_permissions.get(&entry.id) == Some(&Mode::All) {
|
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))
|
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"));
|
.backing_on_hover_text(tr("ui.ai_mode_all_warning"));
|
||||||
}
|
}
|
||||||
test_line(ui, &app.agent_chat.settings.tests.get(&entry.id));
|
test_line(ui, &app.agent_chat.settings.tests.get(&entry.id));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -502,7 +502,8 @@ pub fn paint(app: &SolveApp, ctx: &egui::Context) {
|
|||||||
.interactable(false)
|
.interactable(false)
|
||||||
.fade_in(false)
|
.fade_in(false)
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
Container::popup_frame(ui.style()).fill(t.overlay).show(ui, |ui| {
|
let frame = Container::popup_frame(ui.style()).fill(t.overlay);
|
||||||
|
Container::show_backing(ui, frame, |ui| {
|
||||||
ui.label(egui::RichText::new(crate::i18n::tr_label(label)).size(t.font_label).color(t.text));
|
ui.label(egui::RichText::new(crate::i18n::tr_label(label)).size(t.font_label).color(t.text));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+124
-4
@@ -40,19 +40,24 @@ impl Counters {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn validate_radius(radius: f32) -> Result<f32, &'static str> {
|
pub fn validate_radius(radius: f32) -> Result<f32, &'static str> {
|
||||||
if radius.is_finite() && (0.0..=40.0).contains(&radius) {
|
if radius.is_finite() && (0.0..=60.0).contains(&radius) {
|
||||||
Ok(radius)
|
Ok(radius)
|
||||||
} else {
|
} else {
|
||||||
Err("blur radius must be finite and between 0 and 40 physical pixels")
|
Err("blur radius must be finite and between 0 and 60 physical pixels")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Original Kawase schedule. Offsets are in input texels; the first level is half sized.
|
/// Original Kawase schedule. Offsets are in input texels; the first level is half sized.
|
||||||
pub fn schedule(radius: f32) -> Option<(usize, f32)> {
|
pub fn schedule(radius: f32) -> Option<(usize, f32)> {
|
||||||
|
schedule_capped(radius, 5)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A transient quality cap changes pass count, retaining the requested physical radius.
|
||||||
|
pub fn schedule_capped(radius: f32, cap: usize) -> Option<(usize, f32)> {
|
||||||
if validate_radius(radius).ok()? == 0.0 {
|
if validate_radius(radius).ok()? == 0.0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let steps = if radius <= 8.0 {
|
let steps = (if radius <= 8.0 {
|
||||||
2
|
2
|
||||||
} else if radius <= 18.0 {
|
} else if radius <= 18.0 {
|
||||||
3
|
3
|
||||||
@@ -60,13 +65,120 @@ pub fn schedule(radius: f32) -> Option<(usize, f32)> {
|
|||||||
4
|
4
|
||||||
} else {
|
} else {
|
||||||
5
|
5
|
||||||
};
|
})
|
||||||
|
.min(cap.clamp(2, 5));
|
||||||
// Per-axis tap variance: down 1/2, up 4/3, scaled by input texel size.
|
// Per-axis tap variance: down 1/2, up 4/3, scaled by input texel size.
|
||||||
// Target tap sigma = radius / 2. Resampling adds its own measurable footprint.
|
// Target tap sigma = radius / 2. Resampling adds its own measurable footprint.
|
||||||
let variance = (35.0 / 18.0) * (4.0_f32.powi(steps as i32) - 1.0);
|
let variance = (35.0 / 18.0) * (4.0_f32.powi(steps as i32) - 1.0);
|
||||||
Some((steps, radius / (2.0 * variance.sqrt())))
|
Some((steps, radius / (2.0 * variance.sqrt())))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Evidence from the native pass owner's actual submission, never UI construction time.
|
||||||
|
#[derive(Clone, Copy, Debug)]
|
||||||
|
pub struct PaintedFrame {
|
||||||
|
pub frame: u64,
|
||||||
|
pub interval_ms: Option<f64>,
|
||||||
|
pub continuous: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub(crate) struct Guard {
|
||||||
|
pub cap: usize,
|
||||||
|
pub streak: u8,
|
||||||
|
pub notice: bool,
|
||||||
|
pub opted_out: bool,
|
||||||
|
original: Option<usize>,
|
||||||
|
announced: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Guard {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self { cap: 5, streak: 0, notice: false, opted_out: false, original: None, announced: false }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Guard {
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.streak = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn observe(&mut self, radius: f32, interval_ms: f64, eligible: bool) {
|
||||||
|
let steps = schedule_capped(radius, self.cap).map_or(0, |s| s.0);
|
||||||
|
if self.opted_out || !eligible || !interval_ms.is_finite() || interval_ms <= 22.0 || steps <= 2 {
|
||||||
|
self.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.streak = self.streak.saturating_add(1);
|
||||||
|
if self.streak == 60 {
|
||||||
|
self.original.get_or_insert(self.cap);
|
||||||
|
self.cap = steps - 1;
|
||||||
|
self.reset();
|
||||||
|
if !self.announced {
|
||||||
|
self.notice = true;
|
||||||
|
self.announced = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn undo(&mut self) {
|
||||||
|
if let Some(original) = self.original.take() {
|
||||||
|
self.cap = original;
|
||||||
|
}
|
||||||
|
self.opted_out = true;
|
||||||
|
self.notice = false;
|
||||||
|
self.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Session-only state. Harness inputs never replace a real GPU capability with success.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub(crate) struct Session {
|
||||||
|
pub guard: Guard,
|
||||||
|
pub harness_radius: Option<f32>,
|
||||||
|
pub harness_unavailable: Option<&'static str>,
|
||||||
|
pub evidence_fixture: Option<&'static str>,
|
||||||
|
identity: Option<(u64, u32)>,
|
||||||
|
frame: Option<u64>,
|
||||||
|
painted: Option<u64>,
|
||||||
|
dismissed: std::collections::VecDeque<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
pub fn sample(&mut self, ctx: &egui::Context, revision: u64, radius: f32, available: bool, paint: Option<PaintedFrame>) {
|
||||||
|
let frame = ctx.cumulative_frame_nr();
|
||||||
|
if self.frame == Some(frame) {
|
||||||
|
return;
|
||||||
|
} // egui layout re-passes do not count twice.
|
||||||
|
self.frame = Some(frame);
|
||||||
|
let identity = (revision, radius.to_bits());
|
||||||
|
if self.identity.as_ref() != Some(&identity) {
|
||||||
|
self.identity = Some(identity);
|
||||||
|
self.painted = paint.map(|p| p.frame);
|
||||||
|
self.guard.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let eligible = available
|
||||||
|
&& radius > 0.0
|
||||||
|
&& ctx.requested_repaint_last_pass()
|
||||||
|
&& ctx.input(|i| i.viewport().visible() != Some(false))
|
||||||
|
&& paint.is_some_and(|p| p.frame.checked_add(1) == Some(frame) && self.painted != Some(p.frame) && p.continuous);
|
||||||
|
self.painted = paint.map(|p| p.frame);
|
||||||
|
self.guard.observe(radius, paint.and_then(|p| p.interval_ms).unwrap_or(0.0), eligible);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dismissed(&self, key: u64) -> bool {
|
||||||
|
self.dismissed.contains(&key)
|
||||||
|
}
|
||||||
|
pub fn dismiss(&mut self, key: u64) {
|
||||||
|
if !self.dismissed(key) {
|
||||||
|
if self.dismissed.len() == 64 {
|
||||||
|
self.dismissed.pop_front();
|
||||||
|
}
|
||||||
|
self.dismissed.push_back(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
#[path = "blur_native.rs"]
|
#[path = "blur_native.rs"]
|
||||||
mod implementation;
|
mod implementation;
|
||||||
@@ -100,6 +212,14 @@ mod implementation {
|
|||||||
}
|
}
|
||||||
pub fn begin(&mut self, _: &egui::Context, _: bool) {}
|
pub fn begin(&mut self, _: &egui::Context, _: bool) {}
|
||||||
pub fn set_surface_alpha(&mut self, _: Option<u8>) {}
|
pub fn set_surface_alpha(&mut self, _: Option<u8>) {}
|
||||||
|
pub fn set_available(&mut self, _: bool) {}
|
||||||
|
pub fn set_tier_cap(&mut self, _: usize) {}
|
||||||
|
pub fn software_adapter(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
pub fn painted_frame(&self) -> Option<PaintedFrame> {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
pub fn enabled() -> bool {
|
pub fn enabled() -> bool {
|
||||||
false
|
false
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Original SolidCraft viewport-only dual-Kawase and rounded panel composition.
|
// Original SolidCraft backdrop dual-Kawase and rounded surface composition.
|
||||||
struct Params { a: vec4<f32>, rect: vec4<f32>, corners: vec4<f32>, top: vec4<f32>, bottom: vec4<f32>, outside: vec4<f32> }
|
struct Params { a: vec4<f32>, rect: vec4<f32>, corners: vec4<f32>, top: vec4<f32>, bottom: vec4<f32>, outside: vec4<f32> }
|
||||||
@group(0) @binding(0) var image: texture_2d<f32>;
|
@group(0) @binding(0) var image: texture_2d<f32>;
|
||||||
@group(0) @binding(1) var filtering: sampler;
|
@group(0) @binding(1) var filtering: sampler;
|
||||||
@@ -16,7 +16,7 @@ fn gamma(c: vec3<f32>) -> vec3<f32> {
|
|||||||
}
|
}
|
||||||
fn load_linear(at: vec2<i32>) -> vec4<f32> {
|
fn load_linear(at: vec2<i32>) -> vec4<f32> {
|
||||||
let c = textureLoad(image, clamp(at, vec2<i32>(0), vec2<i32>(textureDimensions(image)) - vec2<i32>(1)), 0);
|
let c = textureLoad(image, clamp(at, vec2<i32>(0), vec2<i32>(textureDimensions(image)) - vec2<i32>(1)), 0);
|
||||||
// Only the source is gamma encoded on unorm targets. It is opaque, so decoding
|
// Only the scene/composition input is gamma encoded on unorm targets. Decoding
|
||||||
// each texel BEFORE bilinear interpolation avoids filtering encoded colors.
|
// each texel BEFORE bilinear interpolation avoids filtering encoded colors.
|
||||||
return vec4<f32>(linear(c.rgb), c.a);
|
return vec4<f32>(linear(c.rgb), c.a);
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,7 @@ fn sample_at(uv: vec2<f32>) -> vec4<f32> {
|
|||||||
let alpha = clamp(0.5 - distance, 0.0, 1.0) * p.a.w;
|
let alpha = clamp(0.5 - distance, 0.0, 1.0) * p.a.w;
|
||||||
let c = textureSample(image, filtering, v.position.xy / vec2<f32>(textureDimensions(image)));
|
let c = textureSample(image, filtering, v.position.xy / vec2<f32>(textureDimensions(image)));
|
||||||
// Match egui: coverage/opacity multiply gamma-premultiplied RGB BEFORE the
|
// Match egui: coverage/opacity multiply gamma-premultiplied RGB BEFORE the
|
||||||
// framebuffer conversion. B itself remains linear, opaque viewport color.
|
// framebuffer conversion. The sampled backdrop remains linear color.
|
||||||
let premultiplied_gamma = gamma(c.rgb) * alpha;
|
let premultiplied_gamma = gamma(c.rgb) * alpha;
|
||||||
let rgb = select(premultiplied_gamma, linear(premultiplied_gamma), p.a.z > 0.5);
|
let rgb = select(premultiplied_gamma, linear(premultiplied_gamma), p.a.z > 0.5);
|
||||||
return vec4<f32>(rgb, alpha);
|
return vec4<f32>(rgb, alpha);
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ const BUDGET: u64 = 256 * 1024 * 1024;
|
|||||||
const MAX_MARKERS: usize = 256;
|
const MAX_MARKERS: usize = 256;
|
||||||
type Params = [f32; 24];
|
type Params = [f32; 24];
|
||||||
|
|
||||||
fn capability(adapter: &wgpu::Adapter, format: wgpu::TextureFormat, split: bool, msaa: u32) -> Capability {
|
fn capability(adapter: &wgpu::Adapter, device_type: wgpu::DeviceType, format: wgpu::TextureFormat, split: bool, msaa: u32) -> Capability {
|
||||||
if solidcraft_gpu::select::tier_of(adapter.get_info().device_type) == solidcraft_gpu::select::Tier::Software {
|
if device_type == wgpu::DeviceType::Cpu {
|
||||||
return Capability::Unavailable("software adapter (gpu-software)");
|
return Capability::Unavailable("software adapter (gpu-software)");
|
||||||
}
|
}
|
||||||
rendering_capability(adapter, format, split, msaa)
|
rendering_capability(adapter, format, split, msaa)
|
||||||
@@ -38,6 +38,7 @@ fn rendering_capability(adapter: &wgpu::Adapter, format: wgpu::TextureFormat, sp
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct Host {
|
struct Host {
|
||||||
adapter: wgpu::Adapter,
|
adapter: wgpu::Adapter,
|
||||||
|
device_type: wgpu::DeviceType,
|
||||||
device: wgpu::Device,
|
device: wgpu::Device,
|
||||||
queue: wgpu::Queue,
|
queue: wgpu::Queue,
|
||||||
renderer: std::sync::Weak<egui::epaint::mutex::RwLock<egui_wgpu::Renderer>>,
|
renderer: std::sync::Weak<egui::epaint::mutex::RwLock<egui_wgpu::Renderer>>,
|
||||||
@@ -49,12 +50,14 @@ struct Host {
|
|||||||
}
|
}
|
||||||
impl Host {
|
impl Host {
|
||||||
fn capability(&self) -> Capability {
|
fn capability(&self) -> Capability {
|
||||||
*self.capability.get_or_init(|| capability(&self.adapter, self.format, self.split, self.msaa))
|
*self.capability.get_or_init(|| capability(&self.adapter, self.device_type, self.format, self.split, self.msaa))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct State {
|
pub struct State {
|
||||||
radius: f32,
|
radius: f32,
|
||||||
|
available: bool,
|
||||||
|
tier_cap: usize,
|
||||||
host: Option<Host>,
|
host: Option<Host>,
|
||||||
active: Option<Arc<Shared>>,
|
active: Option<Arc<Shared>>,
|
||||||
retired: Counters,
|
retired: Counters,
|
||||||
@@ -69,6 +72,8 @@ impl Default for State {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
radius: 0.0,
|
radius: 0.0,
|
||||||
|
available: true,
|
||||||
|
tier_cap: 5,
|
||||||
host: None,
|
host: None,
|
||||||
active: None,
|
active: None,
|
||||||
retired: Counters::default(),
|
retired: Counters::default(),
|
||||||
@@ -87,6 +92,7 @@ impl State {
|
|||||||
self.failure = None;
|
self.failure = None;
|
||||||
self.host = Some(Host {
|
self.host = Some(Host {
|
||||||
adapter: rs.adapter.clone(),
|
adapter: rs.adapter.clone(),
|
||||||
|
device_type: rs.adapter.get_info().device_type,
|
||||||
device: rs.device.clone(),
|
device: rs.device.clone(),
|
||||||
queue: rs.queue.clone(),
|
queue: rs.queue.clone(),
|
||||||
renderer: Arc::downgrade(&rs.renderer),
|
renderer: Arc::downgrade(&rs.renderer),
|
||||||
@@ -124,6 +130,25 @@ impl State {
|
|||||||
pub fn radius(&self) -> f32 {
|
pub fn radius(&self) -> f32 {
|
||||||
self.radius
|
self.radius
|
||||||
}
|
}
|
||||||
|
pub fn software_adapter(&self) -> bool {
|
||||||
|
self.host.as_ref().is_some_and(|h| h.device_type == wgpu::DeviceType::Cpu)
|
||||||
|
}
|
||||||
|
pub fn set_available(&mut self, available: bool) {
|
||||||
|
self.available = available;
|
||||||
|
if !available {
|
||||||
|
self.retire();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn set_tier_cap(&mut self, cap: usize) {
|
||||||
|
let cap = cap.clamp(2, 5);
|
||||||
|
if self.tier_cap != cap {
|
||||||
|
self.retire();
|
||||||
|
self.tier_cap = cap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn painted_frame(&self) -> Option<PaintedFrame> {
|
||||||
|
self.active.as_ref()?.inner.lock().ok()?.painted
|
||||||
|
}
|
||||||
pub fn capability(&self) -> Capability {
|
pub fn capability(&self) -> Capability {
|
||||||
let base = self.base_capability();
|
let base = self.base_capability();
|
||||||
if base == Capability::Available && self.active.as_ref().is_some_and(|s| s.inner.lock().is_ok_and(|i| i.missing_source)) {
|
if base == Capability::Available && self.active.as_ref().is_some_and(|s| s.inner.lock().is_ok_and(|i| i.missing_source)) {
|
||||||
@@ -207,7 +232,7 @@ impl State {
|
|||||||
self.last_size = Some(size_key);
|
self.last_size = Some(size_key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if self.base_capability() != Capability::Available || !size.is_finite() || size.x <= 0.0 || size.y <= 0.0 {
|
if !self.available || self.base_capability() != Capability::Available || !size.is_finite() || size.x <= 0.0 || size.y <= 0.0 {
|
||||||
self.retire();
|
self.retire();
|
||||||
return Scope { previous, alpha };
|
return Scope { previous, alpha };
|
||||||
}
|
}
|
||||||
@@ -216,6 +241,7 @@ impl State {
|
|||||||
Arc::new(Shared {
|
Arc::new(Shared {
|
||||||
host: host.clone(),
|
host: host.clone(),
|
||||||
radius: self.radius,
|
radius: self.radius,
|
||||||
|
tier_cap: self.tier_cap,
|
||||||
painting_owner: std::sync::atomic::AtomicBool::new(true),
|
painting_owner: std::sync::atomic::AtomicBool::new(true),
|
||||||
inner: Mutex::new(Inner { counts: self.retired, ..Inner::default() }),
|
inner: Mutex::new(Inner { counts: self.retired, ..Inner::default() }),
|
||||||
})
|
})
|
||||||
@@ -225,6 +251,12 @@ impl State {
|
|||||||
inner.serial = inner.serial.wrapping_add(1);
|
inner.serial = inner.serial.wrapping_add(1);
|
||||||
inner.source_seen = false;
|
inner.source_seen = false;
|
||||||
inner.source_count = 0;
|
inner.source_count = 0;
|
||||||
|
if inner.frame != ctx.cumulative_frame_nr() {
|
||||||
|
inner.frame = ctx.cumulative_frame_nr();
|
||||||
|
inner.continuous = ctx.requested_repaint_last_pass();
|
||||||
|
}
|
||||||
|
inner.rendered = false;
|
||||||
|
inner.drawn_before = inner.counts.markers_drawn;
|
||||||
}
|
}
|
||||||
if let Some(renderer) = host.renderer.upgrade() {
|
if let Some(renderer) = host.renderer.upgrade() {
|
||||||
renderer.write().frame_compositor = Some(shared.clone());
|
renderer.write().frame_compositor = Some(shared.clone());
|
||||||
@@ -253,8 +285,11 @@ pub(crate) fn fixture_state(
|
|||||||
) -> State {
|
) -> State {
|
||||||
State {
|
State {
|
||||||
radius: 0.0,
|
radius: 0.0,
|
||||||
|
available: true,
|
||||||
|
tier_cap: 5,
|
||||||
host: Some(Host {
|
host: Some(Host {
|
||||||
adapter: adapter.clone(),
|
adapter: adapter.clone(),
|
||||||
|
device_type: adapter.get_info().device_type,
|
||||||
device: device.clone(),
|
device: device.clone(),
|
||||||
queue: queue.clone(),
|
queue: queue.clone(),
|
||||||
renderer: Arc::downgrade(renderer),
|
renderer: Arc::downgrade(renderer),
|
||||||
@@ -317,11 +352,18 @@ pub fn panel_fill(fill: Color32) -> Color32 {
|
|||||||
struct Shared {
|
struct Shared {
|
||||||
host: Host,
|
host: Host,
|
||||||
radius: f32,
|
radius: f32,
|
||||||
|
tier_cap: usize,
|
||||||
inner: Mutex<Inner>,
|
inner: Mutex<Inner>,
|
||||||
painting_owner: std::sync::atomic::AtomicBool,
|
painting_owner: std::sync::atomic::AtomicBool,
|
||||||
}
|
}
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct Inner {
|
struct Inner {
|
||||||
|
frame: u64,
|
||||||
|
continuous: bool,
|
||||||
|
rendered: bool,
|
||||||
|
drawn_before: u64,
|
||||||
|
painted: Option<PaintedFrame>,
|
||||||
|
submitted_at: Option<std::time::Instant>,
|
||||||
gpu: Option<Gpu>,
|
gpu: Option<Gpu>,
|
||||||
markers: Vec<Marker>,
|
markers: Vec<Marker>,
|
||||||
counts: Counters,
|
counts: Counters,
|
||||||
@@ -340,10 +382,14 @@ struct Marker {
|
|||||||
corners: egui::CornerRadius,
|
corners: egui::CornerRadius,
|
||||||
opacity: f32,
|
opacity: f32,
|
||||||
snap: bool,
|
snap: bool,
|
||||||
|
// Identity only: the paint job owns this callback for the entire frame. Keeping
|
||||||
|
// its Arc here would create a Shared -> callback -> Shared ownership cycle.
|
||||||
|
callback_id: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn decorate(painter: &Painter, frame: Frame, content: Rect, ordinary: Shape) -> Shape {
|
pub fn decorate(painter: &Painter, frame: Frame, content: Rect, ordinary: Shape) -> Shape {
|
||||||
if frame.fill.a() == 255 || frame.fill.a() == 0 || !painter.is_visible() {
|
let opacity = painter.opacity();
|
||||||
|
if frame.fill.a() == 0 || (frame.fill.a() == 255 && opacity >= 1.0) || !opacity.is_finite() || opacity <= 0.0 || !painter.is_visible() {
|
||||||
return ordinary;
|
return ordinary;
|
||||||
}
|
}
|
||||||
CURRENT.with(|c| {
|
CURRENT.with(|c| {
|
||||||
@@ -356,7 +402,7 @@ pub fn decorate(painter: &Painter, frame: Frame, content: Rect, ordinary: Shape)
|
|||||||
if !rect.is_finite() || !rect.is_positive() {
|
if !rect.is_finite() || !rect.is_positive() {
|
||||||
return ordinary;
|
return ordinary;
|
||||||
}
|
}
|
||||||
let index = if let Ok(mut inner) = shared.inner.lock() {
|
let callback = if let Ok(mut inner) = shared.inner.lock() {
|
||||||
if inner.markers.len() >= MAX_MARKERS {
|
if inner.markers.len() >= MAX_MARKERS {
|
||||||
inner.failure = Some("blur panel limit exceeded");
|
inner.failure = Some("blur panel limit exceeded");
|
||||||
inner.gpu = None;
|
inner.gpu = None;
|
||||||
@@ -364,18 +410,19 @@ pub fn decorate(painter: &Painter, frame: Frame, content: Rect, ordinary: Shape)
|
|||||||
return ordinary;
|
return ordinary;
|
||||||
}
|
}
|
||||||
let index = inner.markers.len();
|
let index = inner.markers.len();
|
||||||
|
let callback = egui_wgpu::Callback::new_paint_callback(rect, PanelMarker { shared: shared.clone(), index });
|
||||||
inner.markers.push(Marker {
|
inner.markers.push(Marker {
|
||||||
rect,
|
rect,
|
||||||
corners: frame.corner_radius,
|
corners: frame.corner_radius,
|
||||||
opacity: painter.opacity(),
|
opacity,
|
||||||
snap: painter.ctx().tessellation_options(|o| o.round_rects_to_pixels),
|
snap: painter.ctx().tessellation_options(|o| o.round_rects_to_pixels),
|
||||||
|
callback_id: Arc::as_ptr(&callback.callback) as *const () as usize,
|
||||||
});
|
});
|
||||||
inner.counts.markers_emitted += 1;
|
inner.counts.markers_emitted += 1;
|
||||||
index
|
callback
|
||||||
} else {
|
} else {
|
||||||
return ordinary;
|
return ordinary;
|
||||||
};
|
};
|
||||||
let callback = egui_wgpu::Callback::new_paint_callback(rect, PanelMarker { shared: shared.clone(), index });
|
|
||||||
Shape::Vec(vec![Shape::Callback(callback), ordinary])
|
Shape::Vec(vec![Shape::Callback(callback), ordinary])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -477,7 +524,7 @@ impl CallbackTrait for Source {
|
|||||||
return Vec::new();
|
return Vec::new();
|
||||||
}
|
}
|
||||||
inner.source_seen = true;
|
inner.source_seen = true;
|
||||||
let Some((steps, offset)) = schedule(self.shared.radius) else { return Vec::new() };
|
let Some((steps, offset)) = schedule_capped(self.shared.radius, self.shared.tier_cap) else { return Vec::new() };
|
||||||
let Some(bytes) = target_bytes(screen.size_in_pixels, steps, self.shared.host.device.limits().max_texture_dimension_2d) else {
|
let Some(bytes) = target_bytes(screen.size_in_pixels, steps, self.shared.host.device.limits().max_texture_dimension_2d) else {
|
||||||
inner.failure = Some("blur target is empty, exceeds device limits or memory budget");
|
inner.failure = Some("blur target is empty, exceeds device limits or memory budget");
|
||||||
inner.gpu = None;
|
inner.gpu = None;
|
||||||
@@ -539,13 +586,9 @@ impl CallbackTrait for Source {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
counts.source_passes += 1;
|
counts.source_passes += 1;
|
||||||
for step in &gpu.chain {
|
// The backdrop pyramid is built at paint-order boundaries in render(),
|
||||||
let mut pass = pass(encoder, "blur_kawase", &step.target.view, wgpu::Color::TRANSPARENT);
|
// after the underlying UI has actually been drawn, rather than from this
|
||||||
pass.set_pipeline(if step.down { &gpu.down } else { &gpu.up });
|
// scene-only texture during preparation.
|
||||||
pass.set_bind_group(0, &step.binding.group, &[]);
|
|
||||||
pass.draw(0..3, 0..1);
|
|
||||||
counts.blur_passes += 1;
|
|
||||||
}
|
|
||||||
gpu.pending = Some(key);
|
gpu.pending = Some(key);
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
@@ -575,6 +618,7 @@ impl Source {
|
|||||||
screen.size_in_pixels.hash(&mut h);
|
screen.size_in_pixels.hash(&mut h);
|
||||||
screen.pixels_per_point.to_bits().hash(&mut h);
|
screen.pixels_per_point.to_bits().hash(&mut h);
|
||||||
self.shared.radius.to_bits().hash(&mut h);
|
self.shared.radius.to_bits().hash(&mut h);
|
||||||
|
self.shared.tier_cap.hash(&mut h);
|
||||||
self.shared.host.format.hash(&mut h);
|
self.shared.host.format.hash(&mut h);
|
||||||
h.finish()
|
h.finish()
|
||||||
}
|
}
|
||||||
@@ -672,7 +716,7 @@ impl FrameCompositor for Shared {
|
|||||||
if !self.painting_owner.load(std::sync::atomic::Ordering::Relaxed) {
|
if !self.painting_owner.load(std::sync::atomic::Ordering::Relaxed) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let (composition, pipeline, binding) = {
|
let (composition, pipeline, binding, marker_ids) = {
|
||||||
let Ok(mut inner) = self.inner.lock() else { return false };
|
let Ok(mut inner) = self.inner.lock() else { return false };
|
||||||
if !inner.source_seen {
|
if !inner.source_seen {
|
||||||
inner.missing_source = true;
|
inner.missing_source = true;
|
||||||
@@ -688,14 +732,51 @@ impl FrameCompositor for Shared {
|
|||||||
if gpu.ready_serial != inner.serial || gpu.size != screen.size_in_pixels {
|
if gpu.ready_serial != inner.serial || gpu.size != screen.size_in_pixels {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let data = (gpu.composition.view.clone(), gpu.present.clone(), gpu.presentation.group.clone());
|
let mut marker_ids: Vec<_> = inner.markers.iter().map(|marker| marker.callback_id).collect();
|
||||||
|
marker_ids.sort_unstable();
|
||||||
|
let data = (gpu.composition.view.clone(), gpu.present.clone(), gpu.presentation.group.clone(), marker_ids);
|
||||||
inner.counts.composition_passes += 1;
|
inner.counts.composition_passes += 1;
|
||||||
data
|
data
|
||||||
};
|
};
|
||||||
{
|
{
|
||||||
let color = wgpu::Color { r: clear[0] as f64, g: clear[1] as f64, b: clear[2] as f64, a: clear[3] as f64 };
|
let color = wgpu::Color { r: clear[0] as f64, g: clear[1] as f64, b: clear[2] as f64, a: clear[3] as f64 };
|
||||||
let mut ui = pass(encoder, "blur_ui_composition", &composition, color);
|
let _clear = pass(encoder, "blur_ui_composition", &composition, color);
|
||||||
renderer.render(&mut ui, jobs, screen);
|
}
|
||||||
|
let mut start = 0;
|
||||||
|
let mut mesh_offset = 0;
|
||||||
|
let mut segment_passes = 0;
|
||||||
|
for end in 0..=jobs.len() {
|
||||||
|
let callback_id = jobs.get(end).and_then(|job| {
|
||||||
|
let egui::epaint::Primitive::Callback(callback) = &job.primitive else { return None };
|
||||||
|
Some(Arc::as_ptr(&callback.callback) as *const () as usize)
|
||||||
|
});
|
||||||
|
let marker = callback_id.is_some_and(|identity| marker_ids.binary_search(&identity).is_ok());
|
||||||
|
if !marker && end != jobs.len() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if start < end {
|
||||||
|
let mut ui = load_pass(encoder, "blur_ui_segment", &composition);
|
||||||
|
renderer.render_with_mesh_offset(&mut ui, &jobs[start..end], screen, mesh_offset);
|
||||||
|
mesh_offset += jobs[start..end].iter().filter(|job| matches!(&job.primitive, egui::epaint::Primitive::Mesh(_))).count();
|
||||||
|
segment_passes += 1;
|
||||||
|
}
|
||||||
|
if marker {
|
||||||
|
// The preceding segment is complete before sampling its color target.
|
||||||
|
// Reuse the pyramid, but include previously painted UI in the backdrop.
|
||||||
|
if let Ok(mut inner) = self.inner.lock() {
|
||||||
|
let Inner { gpu, counts, .. } = &mut *inner;
|
||||||
|
if let Some(gpu) = gpu {
|
||||||
|
for step in &gpu.chain {
|
||||||
|
let mut blur = pass(encoder, "blur_backdrop", &step.target.view, wgpu::Color::TRANSPARENT);
|
||||||
|
blur.set_pipeline(if step.down { &gpu.down } else { &gpu.up });
|
||||||
|
blur.set_bind_group(0, &step.binding.group, &[]);
|
||||||
|
blur.draw(0..3, 0..1);
|
||||||
|
counts.blur_passes += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start = end;
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
let mut present = pass(encoder, "blur_present", target, wgpu::Color::TRANSPARENT);
|
let mut present = pass(encoder, "blur_present", target, wgpu::Color::TRANSPARENT);
|
||||||
@@ -704,7 +785,9 @@ impl FrameCompositor for Shared {
|
|||||||
present.draw(0..3, 0..1);
|
present.draw(0..3, 0..1);
|
||||||
}
|
}
|
||||||
if let Ok(mut inner) = self.inner.lock() {
|
if let Ok(mut inner) = self.inner.lock() {
|
||||||
|
inner.counts.composition_passes += segment_passes;
|
||||||
inner.counts.present_passes += 1;
|
inner.counts.present_passes += 1;
|
||||||
|
inner.rendered = true;
|
||||||
}
|
}
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
@@ -713,6 +796,19 @@ impl FrameCompositor for Shared {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if let Ok(mut inner) = self.inner.lock() {
|
if let Ok(mut inner) = self.inner.lock() {
|
||||||
|
if inner.rendered && inner.counts.markers_drawn > inner.drawn_before && !self.host.lost.is_lost() {
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
inner.painted = Some(PaintedFrame {
|
||||||
|
frame: inner.frame,
|
||||||
|
interval_ms: inner.submitted_at.map(|at| now.duration_since(at).as_secs_f64() * 1000.0),
|
||||||
|
continuous: inner.continuous,
|
||||||
|
});
|
||||||
|
inner.submitted_at = Some(now);
|
||||||
|
inner.rendered = false;
|
||||||
|
} else {
|
||||||
|
inner.painted = None;
|
||||||
|
inner.submitted_at = None;
|
||||||
|
}
|
||||||
if self.host.lost.is_lost() {
|
if self.host.lost.is_lost() {
|
||||||
inner.gpu = None;
|
inner.gpu = None;
|
||||||
inner.counts.clear_live();
|
inner.counts.clear_live();
|
||||||
@@ -811,6 +907,19 @@ fn background_params(rect: Rect, clip: Rect, colors: [Color32; 2], outside: Colo
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn pass(encoder: &mut wgpu::CommandEncoder, label: &'static str, view: &wgpu::TextureView, clear: wgpu::Color) -> wgpu::RenderPass<'static> {
|
fn pass(encoder: &mut wgpu::CommandEncoder, label: &'static str, view: &wgpu::TextureView, clear: wgpu::Color) -> wgpu::RenderPass<'static> {
|
||||||
|
attachment_pass(encoder, label, view, wgpu::LoadOp::Clear(clear))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_pass(encoder: &mut wgpu::CommandEncoder, label: &'static str, view: &wgpu::TextureView) -> wgpu::RenderPass<'static> {
|
||||||
|
attachment_pass(encoder, label, view, wgpu::LoadOp::Load)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attachment_pass(
|
||||||
|
encoder: &mut wgpu::CommandEncoder,
|
||||||
|
label: &'static str,
|
||||||
|
view: &wgpu::TextureView,
|
||||||
|
load: wgpu::LoadOp<wgpu::Color>,
|
||||||
|
) -> wgpu::RenderPass<'static> {
|
||||||
encoder
|
encoder
|
||||||
.begin_render_pass(&wgpu::RenderPassDescriptor {
|
.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||||
label: Some(label),
|
label: Some(label),
|
||||||
@@ -818,7 +927,7 @@ fn pass(encoder: &mut wgpu::CommandEncoder, label: &'static str, view: &wgpu::Te
|
|||||||
view,
|
view,
|
||||||
resolve_target: None,
|
resolve_target: None,
|
||||||
depth_slice: None,
|
depth_slice: None,
|
||||||
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(clear), store: wgpu::StoreOp::Store },
|
ops: wgpu::Operations { load, store: wgpu::StoreOp::Store },
|
||||||
})],
|
})],
|
||||||
depth_stencil_attachment: None,
|
depth_stencil_attachment: None,
|
||||||
timestamp_writes: None,
|
timestamp_writes: None,
|
||||||
@@ -931,7 +1040,7 @@ impl Gpu {
|
|||||||
dimensions.push(last.map(|d| d.div_ceil(2)));
|
dimensions.push(last.map(|d| d.div_ceil(2)));
|
||||||
}
|
}
|
||||||
for (index, dims) in dimensions.iter().skip(1).enumerate() {
|
for (index, dims) in dimensions.iter().skip(1).enumerate() {
|
||||||
let input = chain.last().map_or(&source.view, |s| &s.target.view);
|
let input = chain.last().map_or(&composition.view, |s| &s.target.view);
|
||||||
let mut p = [0.0; 24];
|
let mut p = [0.0; 24];
|
||||||
p[0] = offset;
|
p[0] = offset;
|
||||||
p[1] = u8::from(index == 0 && !format.is_srgb()) as f32;
|
p[1] = u8::from(index == 0 && !format.is_srgb()) as f32;
|
||||||
@@ -940,7 +1049,7 @@ impl Gpu {
|
|||||||
chain.push(Step { target, binding, down: true });
|
chain.push(Step { target, binding, down: true });
|
||||||
}
|
}
|
||||||
for dims in dimensions.iter().take(steps).rev() {
|
for dims in dimensions.iter().take(steps).rev() {
|
||||||
let input = chain.last().map_or(&source.view, |s| &s.target.view);
|
let input = chain.last().map_or(&composition.view, |s| &s.target.view);
|
||||||
let mut p = [0.0; 24];
|
let mut p = [0.0; 24];
|
||||||
p[0] = offset;
|
p[0] = offset;
|
||||||
let binding = Binding::new(device, &layout, &sampler, input, p, counts);
|
let binding = Binding::new(device, &layout, &sampler, input, p, counts);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//! viewport, a double-click on a sketch edits it, and a right-click opens the item's menu
|
//! viewport, a double-click on a sketch edits it, and a right-click opens the item's menu
|
||||||
//! ([`crate::context_menu`]).
|
//! ([`crate::context_menu`]).
|
||||||
|
|
||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
use crate::ui_kit::{Container, Surface};
|
use crate::ui_kit::{Container, Surface};
|
||||||
use solidcraft_engine::geom::prelude::*;
|
use solidcraft_engine::geom::prelude::*;
|
||||||
|
|
||||||
@@ -1201,7 +1202,7 @@ fn browser_collapsed_rail(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
crate::scenario::publish_handle("left-browser-toggle", disclosure.center());
|
crate::scenario::publish_handle("left-browser-toggle", disclosure.center());
|
||||||
crate::scenario::publish_target("browser:header", disclosure);
|
crate::scenario::publish_target("browser:header", disclosure);
|
||||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), crate::i18n::tr("ui.browser_title")));
|
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), crate::i18n::tr("ui.browser_title")));
|
||||||
if response.on_hover_text(crate::i18n::tr("ui.browser_expand")).clicked() {
|
if response.backing_on_hover_text(crate::i18n::tr("ui.browser_expand")).clicked() {
|
||||||
app.preferences.timeline_left_browser_collapsed = false;
|
app.preferences.timeline_left_browser_collapsed = false;
|
||||||
}
|
}
|
||||||
let galley = ui.painter().layout_no_wrap(crate::i18n::tr("ui.browser_title").to_owned(), crate::fonts::semibold(t.font_body), t.text);
|
let galley = ui.painter().layout_no_wrap(crate::i18n::tr("ui.browser_title").to_owned(), crate::fonts::semibold(t.font_body), t.text);
|
||||||
@@ -1289,7 +1290,7 @@ fn browser_header(app: &mut SolveApp, ui: &mut egui::Ui, collapsible: bool) {
|
|||||||
ui.painter().galley(pos2(right - galley.size().x, rect.center().y - galley.size().y / 2.0), galley, t.text_dim);
|
ui.painter().galley(pos2(right - galley.size().x, rect.center().y - galley.size().y / 2.0), galley, t.text_dim);
|
||||||
if collapsible {
|
if collapsible {
|
||||||
let tip = crate::i18n::tr(if collapsed { "ui.browser_expand" } else { "ui.browser_collapse" });
|
let tip = crate::i18n::tr(if collapsed { "ui.browser_expand" } else { "ui.browser_collapse" });
|
||||||
if response.on_hover_cursor(egui::CursorIcon::PointingHand).on_hover_text(tip).clicked() {
|
if response.on_hover_cursor(egui::CursorIcon::PointingHand).backing_on_hover_text(tip).clicked() {
|
||||||
if stacked {
|
if stacked {
|
||||||
app.preferences.timeline_left_browser_collapsed = !app.preferences.timeline_left_browser_collapsed;
|
app.preferences.timeline_left_browser_collapsed = !app.preferences.timeline_left_browser_collapsed;
|
||||||
} else if vertical {
|
} else if vertical {
|
||||||
@@ -1716,7 +1717,7 @@ fn occurrence_rows_inner(
|
|||||||
}
|
}
|
||||||
if let Some(resp) = &r.resp {
|
if let Some(resp) = &r.resp {
|
||||||
let inherited = if own_visible && !visible { "\nHidden by a parent placement. Show the parent to reveal this instance." } else { "" };
|
let inherited = if own_visible && !visible { "\nHidden by a parent placement. Show the parent to reveal this instance." } else { "" };
|
||||||
resp.clone().on_hover_ui(|ui| {
|
resp.clone().backing_on_hover_ui(|ui| {
|
||||||
ui.label(&placement.tooltip);
|
ui.label(&placement.tooltip);
|
||||||
if !inherited.is_empty() {
|
if !inherited.is_empty() {
|
||||||
ui.label(inherited.trim_start());
|
ui.label(inherited.trim_start());
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ fn marker_row(app: &SolveApp, ui: &mut egui::Ui, depth: usize) -> egui::Response
|
|||||||
if response.hovered() || response.dragged() {
|
if response.hovered() || response.dragged() {
|
||||||
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
ui.ctx().set_cursor_icon(egui::CursorIcon::Grab);
|
||||||
}
|
}
|
||||||
response.on_hover_text(crate::i18n::tr("ui.history_marker_tip"))
|
response.backing_on_hover_text(crate::i18n::tr("ui.history_marker_tip"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Dragging the marker shows where it lands; letting go rolls the history there, through the
|
/// Dragging the marker shows where it lands; letting go rolls the history there, through the
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
//! command has its geometry, so typing a number sets the value at once; Enter applies, Tab
|
//! command has its geometry, so typing a number sets the value at once; Enter applies, Tab
|
||||||
//! moves on, Esc cancels.
|
//! moves on, Esc cancels.
|
||||||
|
|
||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
use crate::ui_kit::{Container, Surface};
|
use crate::ui_kit::{Container, Surface};
|
||||||
use egui::{Color32, Pos2, Stroke, vec2};
|
use egui::{Color32, Pos2, Stroke, vec2};
|
||||||
use solidcore_record::{Vec2, Vec3};
|
use solidcore_record::{Vec2, Vec3};
|
||||||
@@ -927,7 +928,8 @@ fn value_box_full(
|
|||||||
// Background: under Preferences, Report a bug and other system windows (#9016). The box is
|
// Background: under Preferences, Report a bug and other system windows (#9016). The box is
|
||||||
// already kept clear of the command window (#6608), which floats at Middle with Features.
|
// already kept clear of the command window (#6608), which floats at Middle with Features.
|
||||||
Container::new(ui.ctx(), Surface::Canvas).area(area).fixed_pos(at).pivot(pivot).order(egui::Order::Background).show(ui.ctx(), |ui| {
|
Container::new(ui.ctx(), Surface::Canvas).area(area).fixed_pos(at).pivot(pivot).order(egui::Order::Background).show(ui.ctx(), |ui| {
|
||||||
Container::popup_frame(ui.style()).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| {
|
let frame = Container::popup_frame(ui.style()).inner_margin(egui::Margin::symmetric(6, 3));
|
||||||
|
Container::show_backing(ui, frame, |ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label(egui::RichText::new(crate::i18n::tr_label(label)).color(t.text_dim).size(crate::theme::Tokens::get().font_caption));
|
ui.label(egui::RichText::new(crate::i18n::tr_label(label)).color(t.text_dim).size(crate::theme::Tokens::get().font_caption));
|
||||||
let r = crate::ui_kit::canvas_value_field(ui, id, value, keep_tab);
|
let r = crate::ui_kit::canvas_value_field(ui, id, value, keep_tab);
|
||||||
@@ -1801,7 +1803,8 @@ fn draw_revolve(
|
|||||||
.order(egui::Order::Background)
|
.order(egui::Order::Background)
|
||||||
.interactable(false);
|
.interactable(false);
|
||||||
area.show(ui.ctx(), |ui| {
|
area.show(ui.ctx(), |ui| {
|
||||||
Container::popup_frame(ui.style()).inner_margin(egui::Margin::symmetric(6, 3)).show(ui, |ui| {
|
let frame = Container::popup_frame(ui.style()).inner_margin(egui::Margin::symmetric(6, 3));
|
||||||
|
Container::show_backing(ui, frame, |ui| {
|
||||||
let text = if extent.curved_to {
|
let text = if extent.curved_to {
|
||||||
"To surface".into()
|
"To surface".into()
|
||||||
} else if extent.sweep.is_finite() {
|
} else if extent.sweep.is_finite() {
|
||||||
@@ -1809,7 +1812,7 @@ fn draw_revolve(
|
|||||||
} else {
|
} else {
|
||||||
"Choose target".into()
|
"Choose target".into()
|
||||||
};
|
};
|
||||||
ui.label(text).on_hover_text(crate::i18n::tr_label("Determined by To Object. Choose Angle to edit the angular extent."));
|
ui.label(text).backing_on_hover_text(crate::i18n::tr_label("Determined by To Object. Choose Angle to edit the angular extent."));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else if let Kind::Revolve { angle, .. } = &mut d.kind {
|
} else if let Kind::Revolve { angle, .. } = &mut d.kind {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
//! ships with its help: `every_toolbar_command_has_help` fails otherwise.
|
//! ships with its help: `every_toolbar_command_has_help` fails otherwise.
|
||||||
|
|
||||||
use crate::i18n;
|
use crate::i18n;
|
||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
|
|
||||||
/// The commands that have help text. Each one needs a `help.<id>` message in the catalogs.
|
/// The commands that have help text. Each one needs a `help.<id>` message in the catalogs.
|
||||||
pub const DOCUMENTED: &[&str] = &[
|
pub const DOCUMENTED: &[&str] = &[
|
||||||
@@ -335,7 +336,7 @@ pub fn help(command: &str) -> Option<String> {
|
|||||||
/// with the help of `command` below it when there is one.
|
/// with the help of `command` below it when there is one.
|
||||||
pub fn tooltip(response: egui::Response, command: &str, tip: String) -> egui::Response {
|
pub fn tooltip(response: egui::Response, command: &str, tip: String) -> egui::Response {
|
||||||
let help = help(command);
|
let help = help(command);
|
||||||
response.on_hover_ui(move |ui| {
|
response.backing_on_hover_ui(move |ui| {
|
||||||
ui.set_max_width(300.0);
|
ui.set_max_width(300.0);
|
||||||
ui.label(tip);
|
ui.label(tip);
|
||||||
if let Some(help) = help {
|
if let Some(help) = help {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! Structured configuration rule editing and non-mutating row preview.
|
//! Structured configuration rule editing and non-mutating row preview.
|
||||||
use crate::SolveApp;
|
use crate::SolveApp;
|
||||||
use crate::ui_kit;
|
use crate::ui_kit;
|
||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
use egui::Ui;
|
use egui::Ui;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use solidcraft_engine::doc::config::{Column, ConfigRule, RuleCondition, RuleEffect, RuleOperator};
|
use solidcraft_engine::doc::config::{Column, ConfigRule, RuleCondition, RuleEffect, RuleOperator};
|
||||||
@@ -116,13 +117,13 @@ pub(super) fn show(ui: &mut Ui, app: &mut SolveApp, error: &mut Option<String>,
|
|||||||
if ui_kit::button(ui, crate::i18n::tr_label("Edit"), ui_kit::Button::Normal).clicked() {
|
if ui_kit::button(ui, crate::i18n::tr_label("Edit"), ui_kit::Button::Normal).clicked() {
|
||||||
editor.rule = rule.clone();
|
editor.rule = rule.clone();
|
||||||
}
|
}
|
||||||
let up = ui_kit::button(ui, "↑", ui_kit::Button::Enabled(index > 0)).on_hover_text(crate::i18n::tr_label("Move earlier"));
|
let up = ui_kit::button(ui, "↑", ui_kit::Button::Enabled(index > 0)).backing_on_hover_text(crate::i18n::tr_label("Move earlier"));
|
||||||
crate::scenario::publish_handle(&format!("config:rule:{}:up", rule.id), up.rect.center());
|
crate::scenario::publish_handle(&format!("config:rule:{}:up", rule.id), up.rect.center());
|
||||||
if up.clicked() {
|
if up.clicked() {
|
||||||
*error = app.run("config.rule", json!({"id":rule.id,"index":index-1})).err();
|
*error = app.run("config.rule", json!({"id":rule.id,"index":index-1})).err();
|
||||||
}
|
}
|
||||||
if ui_kit::button(ui, "↓", ui_kit::Button::Enabled(index + 1 < rules.len()))
|
if ui_kit::button(ui, "↓", ui_kit::Button::Enabled(index + 1 < rules.len()))
|
||||||
.on_hover_text(crate::i18n::tr_label("Move later"))
|
.backing_on_hover_text(crate::i18n::tr_label("Move later"))
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
*error = app.run("config.rule", json!({"id":rule.id,"index":index+1})).err();
|
*error = app.run("config.rule", json!({"id":rule.id,"index":index+1})).err();
|
||||||
|
|||||||
@@ -653,8 +653,8 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
|
|||||||
"ui.blur" => {
|
"ui.blur" => {
|
||||||
// Internal rendering harness; deliberately no saved preference or product UI.
|
// Internal rendering harness; deliberately no saved preference or product UI.
|
||||||
let radius = if let Some(radius) = p.get("radius_px") {
|
let radius = if let Some(radius) = p.get("radius_px") {
|
||||||
let Some(radius) = radius.as_f64().filter(|v| v.is_finite() && (0.0..=40.0).contains(v)) else {
|
let Some(radius) = radius.as_f64().filter(|v| v.is_finite() && (0.0..=60.0).contains(v)) else {
|
||||||
return err("radius_px must be finite and between 0 and 40");
|
return err("radius_px must be finite and between 0 and 60");
|
||||||
};
|
};
|
||||||
Some(radius as f32)
|
Some(radius as f32)
|
||||||
} else {
|
} else {
|
||||||
@@ -673,18 +673,70 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) ->
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
// Explicit, labelled evidence fixtures only. They can force a failure, never fake
|
||||||
|
// real renderer support. All validation precedes mutation, including old fields.
|
||||||
|
let fixture = if let Some(fixture) = p.get("fixture") {
|
||||||
|
let Some(fixture) =
|
||||||
|
fixture.as_str().filter(|s| matches!(*s, "clear" | "gpu-software" | "reduce-transparency" | "web-unsupported" | "guard-60"))
|
||||||
|
else {
|
||||||
|
return err("unknown blur evidence fixture");
|
||||||
|
};
|
||||||
|
Some(fixture)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
if let Some(radius) = radius {
|
if let Some(radius) = radius {
|
||||||
if let Err(error) = app.blur.set_radius(radius) {
|
if let Err(error) = app.blur.set_radius(radius) {
|
||||||
return err(error);
|
return err(error);
|
||||||
}
|
}
|
||||||
|
app.blur_session.harness_radius = Some(radius);
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
if let Some(alpha) = alpha {
|
if let Some(alpha) = alpha {
|
||||||
app.blur.set_surface_alpha(alpha);
|
app.blur.set_surface_alpha(alpha);
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
|
if let Some(fixture) = fixture {
|
||||||
|
match fixture {
|
||||||
|
"clear" => {
|
||||||
|
app.blur_session.harness_unavailable = None;
|
||||||
|
app.blur_session.harness_radius = None;
|
||||||
|
app.blur_session.evidence_fixture = None;
|
||||||
|
}
|
||||||
|
"gpu-software" => {
|
||||||
|
app.blur_session.harness_unavailable = Some("prefs.blur_software");
|
||||||
|
app.blur_session.evidence_fixture = Some("gpu-software");
|
||||||
|
}
|
||||||
|
"reduce-transparency" => {
|
||||||
|
app.blur_session.harness_unavailable = Some("prefs.blur_reduce_transparency");
|
||||||
|
app.blur_session.evidence_fixture = Some("reduce-transparency");
|
||||||
|
}
|
||||||
|
"web-unsupported" => {
|
||||||
|
app.blur_session.harness_unavailable = Some("prefs.blur_web");
|
||||||
|
app.blur_session.evidence_fixture = Some("web-unsupported");
|
||||||
|
}
|
||||||
|
"guard-60" => {
|
||||||
|
let radius = app.blur.radius();
|
||||||
|
for _ in 0..60 {
|
||||||
|
app.blur_session.guard.observe(radius, 23.0, true);
|
||||||
|
}
|
||||||
|
app.blur_session.evidence_fixture = Some("guard-60");
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
ctx.request_repaint();
|
||||||
|
}
|
||||||
|
let availability = crate::prefs::blur_availability(app);
|
||||||
|
let reason = match availability {
|
||||||
|
crate::theme_options::Availability::Unavailable(reason) => Some(app.tr(reason)),
|
||||||
|
crate::theme_options::Availability::Available => None,
|
||||||
|
};
|
||||||
ok(
|
ok(
|
||||||
json!({"radius_px":app.blur.radius(), "max_radius_px":40, "capability":app.blur.capability_for(ctx.viewport_id()), "counters":app.blur.counters()}),
|
json!({"radius_px":app.blur.radius(), "max_radius_px":60, "capability":app.blur.capability_for(ctx.viewport_id()), "counters":app.blur.counters(),
|
||||||
|
"available": availability == crate::theme_options::Availability::Available, "reason": reason,
|
||||||
|
"evidence_fixture": app.blur_session.evidence_fixture,
|
||||||
|
"guard": {"tier_cap": app.blur_session.guard.cap, "streak": app.blur_session.guard.streak,
|
||||||
|
"notice": app.blur_session.guard.notice, "opted_out": app.blur_session.guard.opted_out}}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
"ui.status_fixture" => wrap(crate::viewport_status_fixture::control(app, p)),
|
"ui.status_fixture" => wrap(crate::viewport_status_fixture::control(app, p)),
|
||||||
@@ -1694,7 +1746,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn blur_control_validates_before_mutation_and_reports_no_gpu() {
|
fn blur_control_validates_before_mutation_and_reports_no_gpu() {
|
||||||
let mut h = crate::scenario::Harness::new();
|
let mut h = crate::scenario::Harness::new();
|
||||||
for invalid in [json!(-1), json!(41), json!("18"), json!(null)] {
|
for invalid in [json!(-1), json!(61), json!("18"), json!(null)] {
|
||||||
let reply = h.call("ui.blur", json!({"radius_px":invalid}));
|
let reply = h.call("ui.blur", json!({"radius_px":invalid}));
|
||||||
assert_eq!(reply["ok"], false, "{reply}");
|
assert_eq!(reply["ok"], false, "{reply}");
|
||||||
assert_eq!(h.app.blur.radius(), 0.0);
|
assert_eq!(h.app.blur.radius(), 0.0);
|
||||||
@@ -1712,6 +1764,43 @@ mod tests {
|
|||||||
assert_eq!(h.app.blur.radius(), 0.0);
|
assert_eq!(h.app.blur.radius(), 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blur_evidence_fixtures_are_atomic_transient_and_never_fake_support() {
|
||||||
|
let mut h = crate::scenario::Harness::new();
|
||||||
|
let source: Value = serde_json::from_str(include_str!("../tests/fixtures/theme-blur.json")).unwrap();
|
||||||
|
assert_eq!(h.call("ui.prefs", json!({"theme_import":source,"theme_options":{"blur-review":{"blur":40}}}))["ok"], true);
|
||||||
|
h.frames(2);
|
||||||
|
let saved = h.app.preferences.theme_options.clone();
|
||||||
|
let revision = h.app.session.revision;
|
||||||
|
for invalid in [json!(null), json!([]), json!("available"), json!("unknown")] {
|
||||||
|
assert_eq!(h.call("ui.blur", json!({"fixture":invalid,"radius_px":8}))["ok"], false);
|
||||||
|
assert_eq!(h.app.blur.radius(), 40.0);
|
||||||
|
assert_eq!(h.app.blur_session.harness_radius, None);
|
||||||
|
}
|
||||||
|
for fixture in ["gpu-software", "reduce-transparency", "web-unsupported"] {
|
||||||
|
let reply = h.call("ui.blur", json!({"fixture":fixture}));
|
||||||
|
assert_eq!(reply["result"]["evidence_fixture"], fixture);
|
||||||
|
assert_eq!(reply["result"]["available"], false);
|
||||||
|
h.frames(2);
|
||||||
|
assert_eq!(h.app.blur.counters().textures_created, 0);
|
||||||
|
assert_eq!(h.app.blur.counters().markers_emitted, 0);
|
||||||
|
assert_eq!(h.app.preferences.theme_options, saved);
|
||||||
|
}
|
||||||
|
h.call("ui.blur", json!({"fixture":"clear"}));
|
||||||
|
h.call("ui.blur", json!({"fixture":"guard-60"}));
|
||||||
|
assert_eq!((h.app.blur_session.guard.cap, h.app.blur_session.guard.notice), (4, true));
|
||||||
|
assert_eq!(h.app.blur_session.evidence_fixture, Some("guard-60"));
|
||||||
|
h.app.blur_session.guard.undo();
|
||||||
|
assert_eq!((h.app.blur_session.guard.cap, h.app.blur_session.guard.opted_out), (5, true));
|
||||||
|
h.call("ui.blur", json!({"fixture":"guard-60"}));
|
||||||
|
assert_eq!(h.app.blur_session.guard.cap, 5);
|
||||||
|
assert_eq!(h.app.session.revision, revision, "quality actions never enter document undo");
|
||||||
|
assert_eq!(h.app.preferences.theme_options, saved);
|
||||||
|
h.call("ui.blur", json!({"fixture":"clear"}));
|
||||||
|
assert!(h.app.blur_session.evidence_fixture.is_none());
|
||||||
|
assert!(matches!(h.app.blur.capability(), crate::blur::Capability::Unavailable(_)), "clearing a fixture cannot manufacture a GPU");
|
||||||
|
}
|
||||||
|
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use solidcraft_engine::{Sel, Session};
|
use solidcraft_engine::{Sel, Session};
|
||||||
|
|
||||||
|
|||||||
+136
-14
@@ -2,6 +2,7 @@
|
|||||||
//! inputs (geometry picked in the viewport, never typed), then runs its command; it never
|
//! inputs (geometry picked in the viewport, never typed), then runs its command; it never
|
||||||
//! changes the design itself. Things selected before the command starts become its input.
|
//! changes the design itself. Things selected before the command starts become its input.
|
||||||
|
|
||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
use crate::ui_kit::choice_row;
|
use crate::ui_kit::choice_row;
|
||||||
use solidcraft_engine::geom::prelude::*;
|
use solidcraft_engine::geom::prelude::*;
|
||||||
use std::hash::{Hash, Hasher};
|
use std::hash::{Hash, Hasher};
|
||||||
@@ -526,6 +527,11 @@ pub struct Dialog {
|
|||||||
pub(crate) chain_applied: bool,
|
pub(crate) chain_applied: bool,
|
||||||
/// A pattern's objects of the Object Type not shown now, restored when it is chosen again.
|
/// A pattern's objects of the Object Type not shown now, restored when it is chosen again.
|
||||||
pub(crate) other_objects: Vec<Sel>,
|
pub(crate) other_objects: Vec<Sel>,
|
||||||
|
/// The multi-pick input whose picked items are listed under it, each removable (#3683).
|
||||||
|
pub(crate) listed: Option<usize>,
|
||||||
|
/// The listed item (and its input) whose row is under the pointer: the view lights it as a
|
||||||
|
/// hover (`viewport::list_hover`).
|
||||||
|
pub(crate) list_hover: Option<(usize, Sel)>,
|
||||||
/// Shell feedback is derived from the picks, never saved as additional face references.
|
/// Shell feedback is derived from the picks, never saved as additional face references.
|
||||||
shell_chain: std::cell::RefCell<Option<ShellChain>>,
|
shell_chain: std::cell::RefCell<Option<ShellChain>>,
|
||||||
/// An extrude Press Pull opened with nothing selected: its first pick may be a curved face or
|
/// An extrude Press Pull opened with nothing selected: its first pick may be a curved face or
|
||||||
@@ -793,6 +799,8 @@ impl Dialog {
|
|||||||
picked_edges: Vec::new(),
|
picked_edges: Vec::new(),
|
||||||
chain_applied,
|
chain_applied,
|
||||||
other_objects: Vec::new(),
|
other_objects: Vec::new(),
|
||||||
|
listed: None,
|
||||||
|
list_hover: None,
|
||||||
shell_chain: Default::default(),
|
shell_chain: Default::default(),
|
||||||
press_pull: false,
|
press_pull: false,
|
||||||
op_auto: Default::default(),
|
op_auto: Default::default(),
|
||||||
@@ -1858,6 +1866,80 @@ impl Dialog {
|
|||||||
inp.items = items;
|
inp.items = items;
|
||||||
self.error = None;
|
self.error = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// What input `i`'s list of picked items shows, one row each (#3683): what its chip counts.
|
||||||
|
/// A rim or a surface split into pieces is one row, as it is one pick; a Shell with Tangent
|
||||||
|
/// Chain lists every face it opens.
|
||||||
|
pub(crate) fn listed_items(&self, s: &Session, i: usize) -> Vec<Sel> {
|
||||||
|
if i == 0
|
||||||
|
&& let Some(faces) = self.shell_faces(s)
|
||||||
|
{
|
||||||
|
return faces;
|
||||||
|
}
|
||||||
|
let mut rows: Vec<Sel> = Vec::new();
|
||||||
|
let mut shown: Vec<Sel> = Vec::new();
|
||||||
|
for item in self.inputs.get(i).map(|inp| inp.items.as_slice()).unwrap_or_default() {
|
||||||
|
if shown.iter().any(|x| selection::same_item(x, item)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
shown.extend(crate::viewport::grouped_selection(s, item));
|
||||||
|
rows.push(item.clone());
|
||||||
|
}
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a listed row's item from input `i`, as picking it again toggles it off: the
|
||||||
|
/// pieces of its rim or surface go with it, and the hand-picked edges and the explicit
|
||||||
|
/// profile choice follow. A face a Shell's tangent chain opens goes with the picks whose
|
||||||
|
/// chain holds it.
|
||||||
|
pub(crate) fn remove_listed(&mut self, s: &Session, i: usize, item: &Sel) {
|
||||||
|
let shell_chain = i == 0 && self.shell_faces(s).is_some();
|
||||||
|
// A Fillet or Chamfer row its Tangent Chain brought goes with the picks whose chain holds
|
||||||
|
// it, as picking it again would; else ticking Tangent Chain again brings it back.
|
||||||
|
let fillet_chain = i == 0 && matches!(self.kind, Kind::Fillet { chain: true, .. }) && self.chain_applied;
|
||||||
|
let state = s.world_state().ok();
|
||||||
|
let mut gone = if shell_chain { Vec::new() } else { crate::viewport::grouped_selection(s, item) };
|
||||||
|
gone.push(item.clone());
|
||||||
|
if fillet_chain {
|
||||||
|
let seeds: Vec<Sel> = self
|
||||||
|
.picked_edges
|
||||||
|
.iter()
|
||||||
|
.filter(|seed| fillet_edges(s, seed, true).iter().any(|x| gone.iter().any(|g| selection::same_item(x, g))))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
for seed in &seeds {
|
||||||
|
gone.extend(fillet_edges(s, seed, true));
|
||||||
|
}
|
||||||
|
gone.extend(seeds);
|
||||||
|
}
|
||||||
|
let Some(inp) = self.inputs.get_mut(i) else { return };
|
||||||
|
match (shell_chain, item, state.as_ref()) {
|
||||||
|
(true, Sel::Face { body, index, .. }, Some(st)) => {
|
||||||
|
let model = st.body(body);
|
||||||
|
inp.items.retain(|pick| {
|
||||||
|
let Sel::Face { body: b, point, .. } = pick else { return true };
|
||||||
|
b != body || !model.is_some_and(|m| solidcraft_engine::doc::tangent_face_chain(m, &[*point]).iter().any(|(f, _)| f == index))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Without the model to find the chain in, the row goes on its own.
|
||||||
|
_ => {
|
||||||
|
inp.items.retain(|x| !gone.iter().any(|g| selection::same_item(x, g)));
|
||||||
|
if i == 0 {
|
||||||
|
self.picked_edges.retain(|x| !gone.iter().any(|g| selection::same_item(x, g)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let empty = inp.items.is_empty();
|
||||||
|
if empty && self.listed == Some(i) {
|
||||||
|
self.listed = None;
|
||||||
|
}
|
||||||
|
if i == 0 && matches!(self.kind, Kind::Extrude { .. } | Kind::Revolve { .. } | Kind::Emboss { .. }) {
|
||||||
|
self.profile_selection_changed = true;
|
||||||
|
}
|
||||||
|
self.list_hover = None;
|
||||||
|
self.error = None;
|
||||||
|
self.sync_thread(s);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The edges a Fillet or Chamfer pick stands for: its tangent chain when `chain` is on, else
|
/// The edges a Fillet or Chamfer pick stands for: its tangent chain when `chain` is on, else
|
||||||
@@ -2593,7 +2675,7 @@ fn object_type_row(d: &mut Dialog, ui: &mut egui::Ui) {
|
|||||||
let cur = usize::from(inp.accept == selection::FEATURES);
|
let cur = usize::from(inp.accept == selection::FEATURES);
|
||||||
let mut ty = cur;
|
let mut ty = cur;
|
||||||
// What the two kinds are, and that a sketch is neither (#10057).
|
// What the two kinds are, and that a sketch is neither (#10057).
|
||||||
choice_row(ui, "Object Type", "pat_obj", &mut ty, &["Bodies", "Features"]).on_hover_text(crate::i18n::tr("ui.pattern_object_type_tip"));
|
choice_row(ui, "Object Type", "pat_obj", &mut ty, &["Bodies", "Features"]).backing_on_hover_text(crate::i18n::tr("ui.pattern_object_type_tip"));
|
||||||
if ty != cur {
|
if ty != cur {
|
||||||
inp.accept = if ty == 1 { selection::FEATURES } else { BODIES };
|
inp.accept = if ty == 1 { selection::FEATURES } else { BODIES };
|
||||||
inp.label = objects_label(inp.accept);
|
inp.label = objects_label(inp.accept);
|
||||||
@@ -2850,6 +2932,8 @@ pub(crate) fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, n
|
|||||||
// Only one field may look active at a time (#10066): while a text editor holds the keyboard,
|
// Only one field may look active at a time (#10066): while a text editor holds the keyboard,
|
||||||
// no pick field draws its accent ring, and clicking a pick field takes the keyboard back.
|
// no pick field draws its accent ring, and clicking a pick field takes the keyboard back.
|
||||||
let text_focus = crate::ui_kit::editor_has_focus(ui.ctx());
|
let text_focus = crate::ui_kit::editor_has_focus(ui.ctx());
|
||||||
|
let mut toggle_list: Option<usize> = None;
|
||||||
|
let mut hovered: Option<(usize, Sel)> = None;
|
||||||
let shell_count = d.shell_faces(s).map(|faces| faces.len());
|
let shell_count = d.shell_faces(s).map(|faces| faces.len());
|
||||||
for (i, inp) in d.inputs.iter().enumerate().skip(range.start).take(range.end.saturating_sub(range.start)) {
|
for (i, inp) in d.inputs.iter().enumerate().skip(range.start).take(range.end.saturating_sub(range.start)) {
|
||||||
let stored_reference = stored_input_reference_key(d, i).and_then(|key| d.extra.get(key)).filter(|_| inp.items.is_empty());
|
let stored_reference = stored_input_reference_key(d, i).and_then(|key| d.extra.get(key)).filter(|_| inp.items.is_empty());
|
||||||
@@ -2904,7 +2988,7 @@ pub(crate) fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, n
|
|||||||
} else {
|
} else {
|
||||||
format!("{} (click to pick into this input)", hint(inp))
|
format!("{} (click to pick into this input)", hint(inp))
|
||||||
};
|
};
|
||||||
if chip.on_hover_text(tooltip).clicked() {
|
if chip.backing_on_hover_text(tooltip).clicked() {
|
||||||
activate = Some(i);
|
activate = Some(i);
|
||||||
surrender_keyboard_focus(ui);
|
surrender_keyboard_focus(ui);
|
||||||
}
|
}
|
||||||
@@ -2919,22 +3003,51 @@ pub(crate) fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, n
|
|||||||
clear = Some(i);
|
clear = Some(i);
|
||||||
surrender_keyboard_focus(ui);
|
surrender_keyboard_focus(ui);
|
||||||
}
|
}
|
||||||
|
// Several picks: an arrow beside the clear button lists them, each with its
|
||||||
|
// own remove button (#3683). Drawn straight into the row, which is this field's
|
||||||
|
// own: a scope around it would take room and widen the dialog.
|
||||||
|
if inp.multi {
|
||||||
|
let open = d.listed == Some(i);
|
||||||
|
let at = rect.translate(vec2(-t.space_24, 0.0));
|
||||||
|
let label = crate::i18n::tr_label(if open { "Hide the picked items" } else { "Show the picked items" });
|
||||||
|
let arrow = crate::ui_kit::disclosure_button_at(ui, at, open, label);
|
||||||
|
crate::scenario::publish_handle(&format!("dialog:list_toggle:{}", inp.label), arrow.rect.center());
|
||||||
|
if arrow.clicked() {
|
||||||
|
toggle_list = Some(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
if matches!(d.kind, Kind::SketchModify(_)) && inp.multi {
|
if inp.multi && d.listed == Some(i) && !inp.items.is_empty() {
|
||||||
for (index, item) in inp.items.iter().enumerate() {
|
let rows = d.listed_items(s, i);
|
||||||
if crate::ui_kit::list_row(ui, &format!("{}:{index}", inp.label), &selection_label(s, names, d, inp, item)).clicked() {
|
egui::ScrollArea::vertical().id_salt(("picked_items", i)).max_height(t.control_height * 6.5).show(ui, |ui| {
|
||||||
remove = Some((i, index));
|
for (index, item) in rows.into_iter().enumerate() {
|
||||||
|
let row = crate::ui_kit::list_row(ui, &format!("{}:{index}", inp.label), &selection_label(s, names, d, inp, &item));
|
||||||
|
if row.hovered() {
|
||||||
|
hovered = Some((i, item.clone()));
|
||||||
|
}
|
||||||
|
if row.clicked() {
|
||||||
|
remove = Some((i, item));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some((input, index)) = remove
|
// The row under the pointer lights its item in the view; rows of inputs drawn elsewhere
|
||||||
&& let Some(input) = d.inputs.get_mut(input)
|
// keep theirs.
|
||||||
&& index < input.items.len()
|
let ours = |h: &Option<(usize, Sel)>| h.as_ref().is_some_and(|(i, _)| range.contains(i));
|
||||||
{
|
if hovered.is_some() || ours(&d.list_hover) {
|
||||||
input.items.remove(index);
|
if d.list_hover != hovered {
|
||||||
|
ui.ctx().request_repaint();
|
||||||
|
}
|
||||||
|
d.list_hover = hovered;
|
||||||
|
}
|
||||||
|
if let Some(i) = toggle_list {
|
||||||
|
d.listed = if d.listed == Some(i) { None } else { Some(i) };
|
||||||
|
}
|
||||||
|
if let Some((input, item)) = remove {
|
||||||
|
d.remove_listed(s, input, &item);
|
||||||
}
|
}
|
||||||
if let Some(i) = activate {
|
if let Some(i) = activate {
|
||||||
d.active = i;
|
d.active = i;
|
||||||
@@ -2947,6 +3060,10 @@ pub(crate) fn input_rows_range(d: &mut Dialog, ui: &mut egui::Ui, s: &Session, n
|
|||||||
if let Some(key) = stored_input_reference_key(d, i) {
|
if let Some(key) = stored_input_reference_key(d, i) {
|
||||||
d.extra.remove(key);
|
d.extra.remove(key);
|
||||||
}
|
}
|
||||||
|
if d.listed == Some(i) {
|
||||||
|
d.listed = None;
|
||||||
|
d.list_hover = None;
|
||||||
|
}
|
||||||
let Some(inp) = d.inputs.get_mut(i) else { return };
|
let Some(inp) = d.inputs.get_mut(i) else { return };
|
||||||
inp.items.clear();
|
inp.items.clear();
|
||||||
if i == 0 {
|
if i == 0 {
|
||||||
@@ -3071,7 +3188,7 @@ fn error_rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui: &mut
|
|||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
match &plain {
|
match &plain {
|
||||||
Some(FilletRefusal::Plain(plain)) => {
|
Some(FilletRefusal::Plain(plain)) => {
|
||||||
crate::ui_kit::text(ui, plain, crate::ui_kit::Text::Report("error", true)).on_hover_text(shown_error(&error));
|
crate::ui_kit::text(ui, plain, crate::ui_kit::Text::Report("error", true)).backing_on_hover_text(shown_error(&error));
|
||||||
}
|
}
|
||||||
Some(FilletRefusal::InField) => {}
|
Some(FilletRefusal::InField) => {}
|
||||||
None => {
|
None => {
|
||||||
@@ -3082,7 +3199,7 @@ fn error_rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui: &mut
|
|||||||
if plain.is_some() {
|
if plain.is_some() {
|
||||||
let copy = crate::ui_kit::button(ui, tr("ui.copy_details"), crate::ui_kit::Button::Normal);
|
let copy = crate::ui_kit::button(ui, tr("ui.copy_details"), crate::ui_kit::Button::Normal);
|
||||||
crate::scenario::publish_handle("dialog:copy_details", copy.rect.center());
|
crate::scenario::publish_handle("dialog:copy_details", copy.rect.center());
|
||||||
if copy.on_hover_text(tr("ui.copy_details_tip")).clicked() {
|
if copy.backing_on_hover_text(tr("ui.copy_details_tip")).clicked() {
|
||||||
ui.ctx().copy_text(error.clone());
|
ui.ctx().copy_text(error.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6199,6 +6316,11 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
|
|||||||
{
|
{
|
||||||
d.extra.insert("corner".into(), json!("g2"));
|
d.extra.insert("corner".into(), json!("g2"));
|
||||||
}
|
}
|
||||||
|
if let FeatureKind::Fillet { turn, .. } = &f.kind
|
||||||
|
&& !turn.is_mitre()
|
||||||
|
{
|
||||||
|
d.extra.insert("turn".into(), json!("swept"));
|
||||||
|
}
|
||||||
// A fillet that reduces or tapers a radius too large for its edges.
|
// A fillet that reduces or tapers a radius too large for its edges.
|
||||||
if let FeatureKind::Fillet { fit, .. } = &f.kind {
|
if let FeatureKind::Fillet { fit, .. } = &f.kind {
|
||||||
use solidcraft_engine::doc::FilletFit;
|
use solidcraft_engine::doc::FilletFit;
|
||||||
|
|||||||
@@ -1468,6 +1468,12 @@ fn dialog_profile_labels_name_the_sketch_in_coil_and_mirror() {
|
|||||||
h.app.start(command);
|
h.app.start(command);
|
||||||
h.app.dialog.as_mut().unwrap().preview_enabled = false;
|
h.app.dialog.as_mut().unwrap().preview_enabled = false;
|
||||||
h.frames(3);
|
h.frames(3);
|
||||||
|
// A field that takes several picks lists them, labels and all, behind its arrow (#3683).
|
||||||
|
let (multi, label) = h.app.dialog.as_ref().map(|d| (d.inputs[0].multi, d.inputs[0].label)).unwrap();
|
||||||
|
if multi {
|
||||||
|
h.step(&json!({"click": {"handle": format!("dialog:list_toggle:{label}")}})).unwrap();
|
||||||
|
h.frames(2);
|
||||||
|
}
|
||||||
let d = h.app.dialog.as_ref().unwrap();
|
let d = h.app.dialog.as_ref().unwrap();
|
||||||
let input = &d.inputs[0];
|
let input = &d.inputs[0];
|
||||||
assert_eq!(input.items, vec![Sel::Profile { sketch, index: 0 }]);
|
assert_eq!(input.items, vec![Sel::Profile { sketch, index: 0 }]);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
//! body or face in the viewport, or onto a body's row in the Browser, applies at once; Apply
|
//! body or face in the viewport, or onto a body's row in the Browser, applies at once; Apply
|
||||||
//! (and OK) gives the picked objects the current look.
|
//! (and OK) gives the picked objects the current look.
|
||||||
|
|
||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
use egui::Color32;
|
use egui::Color32;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use solidcraft_engine::Sel;
|
use solidcraft_engine::Sel;
|
||||||
@@ -415,7 +416,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
|
|||||||
});
|
});
|
||||||
let mut enabled = k.pbr.is_some();
|
let mut enabled = k.pbr.is_some();
|
||||||
let toggle = ui_kit::row(ui, app.tr("appearance.surface_response"), |ui| ui_kit::checkbox(ui, app.tr("appearance.pbr_opaque"), &mut enabled))
|
let toggle = ui_kit::row(ui, app.tr("appearance.surface_response"), |ui| ui_kit::checkbox(ui, app.tr("appearance.pbr_opaque"), &mut enabled))
|
||||||
.on_hover_text(app.tr("appearance.pbr_help"));
|
.backing_on_hover_text(app.tr("appearance.pbr_help"));
|
||||||
crate::scenario::publish_handle("appearance:pbr", toggle.rect.center());
|
crate::scenario::publish_handle("appearance:pbr", toggle.rect.center());
|
||||||
if toggle.changed() {
|
if toggle.changed() {
|
||||||
detach_preset(k);
|
detach_preset(k);
|
||||||
@@ -461,7 +462,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if ui_kit::button(ui, "Remove override", ui_kit::Button::Enabled(has_target))
|
if ui_kit::button(ui, "Remove override", ui_kit::Button::Enabled(has_target))
|
||||||
.on_hover_text(crate::i18n::tr_label("Restore the inherited component or physical material appearance"))
|
.backing_on_hover_text(crate::i18n::tr_label("Restore the inherited component or physical material appearance"))
|
||||||
.clicked()
|
.clicked()
|
||||||
&& let Some(p) = clear_params(k, &picked)
|
&& let Some(p) = clear_params(k, &picked)
|
||||||
{
|
{
|
||||||
@@ -543,7 +544,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.response
|
.response
|
||||||
.on_hover_text(tip);
|
.backing_on_hover_text(tip);
|
||||||
});
|
});
|
||||||
// A swatch dropped on the model or the Browser.
|
// A swatch dropped on the model or the Browser.
|
||||||
let released = ui.input(|i| i.pointer.any_released());
|
let released = ui.input(|i| i.pointer.any_released());
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
//! component shows where it goes, and with Animate the joint plays its motion.
|
//! component shows where it goes, and with Animate the joint plays its motion.
|
||||||
|
|
||||||
use crate::fonts::RichTextExt as _;
|
use crate::fonts::RichTextExt as _;
|
||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
use crate::ui_kit::{choice_row, expr_row, text_row};
|
use crate::ui_kit::{choice_row, expr_row, text_row};
|
||||||
use egui::{Color32, Pos2, RichText, Stroke, vec2};
|
use egui::{Color32, Pos2, RichText, Stroke, vec2};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -737,7 +738,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
|
|||||||
crate::ui_kit::status_line(ui, &why, crate::ui_kit::StatusTone::Warning);
|
crate::ui_kit::status_line(ui, &why, crate::ui_kit::StatusTone::Warning);
|
||||||
if !bodies.is_empty()
|
if !bodies.is_empty()
|
||||||
&& crate::ui_kit::button(ui, crate::i18n::tr("cmd.component_from_bodies"), crate::ui_kit::Button::Normal)
|
&& crate::ui_kit::button(ui, crate::i18n::tr("cmd.component_from_bodies"), crate::ui_kit::Button::Normal)
|
||||||
.on_hover_text(crate::i18n::tr("joint.from_bodies_hint"))
|
.backing_on_hover_text(crate::i18n::tr("joint.from_bodies_hint"))
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
// A refusal shows on the status line; the picks stay either way.
|
// A refusal shows on the status line; the picks stay either way.
|
||||||
@@ -773,7 +774,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Asm, inputs: &mut [Se
|
|||||||
form_row(ui, "Animate", |ui| {
|
form_row(ui, "Animate", |ui| {
|
||||||
let label = crate::i18n::tr(if f.animate { "ui.joint_stop" } else { "ui.joint_play" });
|
let label = crate::i18n::tr(if f.animate { "ui.joint_stop" } else { "ui.joint_play" });
|
||||||
if crate::ui_kit::button(ui, label, crate::ui_kit::Button::Normal)
|
if crate::ui_kit::button(ui, label, crate::ui_kit::Button::Normal)
|
||||||
.on_hover_text(crate::i18n::tr_label("Play the joint's motion in the view"))
|
.backing_on_hover_text(crate::i18n::tr_label("Play the joint's motion in the view"))
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
f.animate = !f.animate;
|
f.animate = !f.animate;
|
||||||
@@ -1034,7 +1035,7 @@ fn find_limits_row(app: &mut SolveApp, ui: &mut egui::Ui, f: &mut JointForm, id:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else if crate::ui_kit::button(ui, crate::i18n::tr("joint.find_limits"), crate::ui_kit::Button::Normal)
|
} else if crate::ui_kit::button(ui, crate::i18n::tr("joint.find_limits"), crate::ui_kit::Button::Normal)
|
||||||
.on_hover_text(crate::i18n::tr("joint.find_limits_hint"))
|
.backing_on_hover_text(crate::i18n::tr("joint.find_limits_hint"))
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
find_limits(app, f, id, inputs);
|
find_limits(app, f, id, inputs);
|
||||||
@@ -1261,7 +1262,7 @@ fn interference_rows(app: &SolveApp, ui: &mut egui::Ui, r: &Value) {
|
|||||||
for u in unchecked {
|
for u in unchecked {
|
||||||
form_row(ui, &format!("{} · {}", u["a"].as_str().unwrap_or(""), u["b"].as_str().unwrap_or("")), |ui| {
|
form_row(ui, &format!("{} · {}", u["a"].as_str().unwrap_or(""), u["b"].as_str().unwrap_or("")), |ui| {
|
||||||
ui.label(RichText::new(crate::i18n::tr("dialog.could_not_be_checked")).color(t.warning))
|
ui.label(RichText::new(crate::i18n::tr("dialog.could_not_be_checked")).color(t.warning))
|
||||||
.on_hover_text(u["reason"].as_str().unwrap_or(""));
|
.backing_on_hover_text(u["reason"].as_str().unwrap_or(""));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1727,7 +1728,7 @@ pub(crate) fn timeline_joint_tiles(app: &mut SolveApp, ui: &mut egui::Ui, p: &eg
|
|||||||
if suppressed {
|
if suppressed {
|
||||||
p.line_segment([br.left_bottom() + vec2(4.0, -4.0), br.right_top() + vec2(-4.0, 4.0)], Stroke::new(1.5, t.text_dim));
|
p.line_segment([br.left_bottom() + vec2(4.0, -4.0), br.right_top() + vec2(-4.0, 4.0)], Stroke::new(1.5, t.text_dim));
|
||||||
}
|
}
|
||||||
let resp = resp.on_hover_text(format!("{name} ({kind:?} joint)\nDouble-click to edit · right-click for more"));
|
let resp = resp.backing_on_hover_text(format!("{name} ({kind:?} joint)\nDouble-click to edit · right-click for more"));
|
||||||
if resp.double_clicked() {
|
if resp.double_clicked() {
|
||||||
open = edit_joint(app, id);
|
open = edit_joint(app, id);
|
||||||
} else if resp.clicked() {
|
} else if resp.clicked() {
|
||||||
@@ -1790,7 +1791,7 @@ pub(crate) fn timeline_joint_rows(app: &mut SolveApp, ui: &mut egui::Ui, ids: Op
|
|||||||
egui::FontId::proportional(crate::theme::Tokens::get().font_body),
|
egui::FontId::proportional(crate::theme::Tokens::get().font_body),
|
||||||
if suppressed { t.text_dim } else { t.text },
|
if suppressed { t.text_dim } else { t.text },
|
||||||
);
|
);
|
||||||
let response = response.on_hover_text(format!("{name} ({kind:?} joint)\nDouble-click to edit · right-click for more"));
|
let response = response.backing_on_hover_text(format!("{name} ({kind:?} joint)\nDouble-click to edit · right-click for more"));
|
||||||
if response.double_clicked() {
|
if response.double_clicked() {
|
||||||
open = edit_joint(app, id);
|
open = edit_joint(app, id);
|
||||||
} else if response.clicked() {
|
} else if response.clicked() {
|
||||||
@@ -1862,7 +1863,7 @@ pub fn browser_joints(app: &mut SolveApp, ui: &mut egui::Ui, depth: usize) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
if let Some(resp) = &r.resp {
|
if let Some(resp) = &r.resp {
|
||||||
resp.clone().on_hover_text(format!("{kind:?} joint"));
|
resp.clone().backing_on_hover_text(format!("{kind:?} joint"));
|
||||||
}
|
}
|
||||||
if r.eye {
|
if r.eye {
|
||||||
let _ = app.run("browser.visibility", json!({ "joints": [id], "visible": !visible }));
|
let _ = app.run("browser.visibility", json!({ "joints": [id], "visible": !visible }));
|
||||||
@@ -1951,7 +1952,7 @@ pub fn component_joints(app: &mut SolveApp, ui: &mut egui::Ui, path: &[u64], key
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
if let Some(resp) = &r.resp {
|
if let Some(resp) = &r.resp {
|
||||||
resp.clone().on_hover_text(format!("{:?} joint", j.kind));
|
resp.clone().backing_on_hover_text(format!("{:?} joint", j.kind));
|
||||||
}
|
}
|
||||||
if r.eye {
|
if r.eye {
|
||||||
let _ = app.run("browser.visibility", json!({ "joints": [id], "visible": !visible }));
|
let _ = app.run("browser.visibility", json!({ "joints": [id], "visible": !visible }));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
//! where it touches) or a flat face. Picking the profile on a part chooses the joint that part
|
//! where it touches) or a flat face. Picking the profile on a part chooses the joint that part
|
||||||
//! turns on. Play turns the cam a full turn so the follower shows its motion before OK.
|
//! turns on. Play turns the cam a full turn so the follower shows its motion before OK.
|
||||||
|
|
||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
use egui::RichText;
|
use egui::RichText;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use solidcraft_engine::Sel;
|
use solidcraft_engine::Sel;
|
||||||
@@ -133,7 +134,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, f: &mut CamForm, inputs: &[Se
|
|||||||
form_row(ui, "Animate", |ui| {
|
form_row(ui, "Animate", |ui| {
|
||||||
let label = crate::i18n::tr(if f.animate { "ui.joint_stop" } else { "ui.joint_play" });
|
let label = crate::i18n::tr(if f.animate { "ui.joint_stop" } else { "ui.joint_play" });
|
||||||
let r = crate::ui_kit::button(ui, label, crate::ui_kit::Button::Normal)
|
let r = crate::ui_kit::button(ui, label, crate::ui_kit::Button::Normal)
|
||||||
.on_hover_text(crate::i18n::tr_label("Play the joint's motion in the view"));
|
.backing_on_hover_text(crate::i18n::tr_label("Play the joint's motion in the view"));
|
||||||
crate::scenario::publish_handle("dialog:Animate", r.rect.center());
|
crate::scenario::publish_handle("dialog:Animate", r.rect.center());
|
||||||
if r.clicked() {
|
if r.clicked() {
|
||||||
f.animate = !f.animate;
|
f.animate = !f.animate;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
//! face also picks the joint it turns on. Play turns the driving joint so the coupling shows its
|
//! face also picks the joint it turns on. Play turns the driving joint so the coupling shows its
|
||||||
//! motion before OK.
|
//! motion before OK.
|
||||||
|
|
||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
use crate::ui_kit::{StatusTone, choice_row, expr_row, status_line};
|
use crate::ui_kit::{StatusTone, choice_row, expr_row, status_line};
|
||||||
use egui::RichText;
|
use egui::RichText;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -206,7 +207,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, f: &mut CouplingForm, inputs:
|
|||||||
form_row(ui, "Animate", |ui| {
|
form_row(ui, "Animate", |ui| {
|
||||||
let label = crate::i18n::tr(if f.animate { "ui.joint_stop" } else { "ui.joint_play" });
|
let label = crate::i18n::tr(if f.animate { "ui.joint_stop" } else { "ui.joint_play" });
|
||||||
let r = crate::ui_kit::button(ui, label, crate::ui_kit::Button::Normal)
|
let r = crate::ui_kit::button(ui, label, crate::ui_kit::Button::Normal)
|
||||||
.on_hover_text(crate::i18n::tr_label("Play the joint's motion in the view"));
|
.backing_on_hover_text(crate::i18n::tr_label("Play the joint's motion in the view"));
|
||||||
crate::scenario::publish_handle("dialog:Animate", r.rect.center());
|
crate::scenario::publish_handle("dialog:Animate", r.rect.center());
|
||||||
if r.clicked() {
|
if r.clicked() {
|
||||||
f.animate = !f.animate;
|
f.animate = !f.animate;
|
||||||
|
|||||||
@@ -134,13 +134,17 @@ fn fillet_corner_tip() -> String {
|
|||||||
/// radius and narrow it only where the faces run out.
|
/// radius and narrow it only where the faces run out.
|
||||||
const FILLET_FITS: [(&str, &str); 3] = [("ui.fillet_fit_exact", "exact"), ("ui.fillet_fit_reduce", "reduce"), ("ui.fillet_fit_taper", "taper")];
|
const FILLET_FITS: [(&str, &str); 3] = [("ui.fillet_fit_exact", "exact"), ("ui.fillet_fit_reduce", "reduce"), ("ui.fillet_fit_taper", "taper")];
|
||||||
|
|
||||||
|
/// How two rounds turn where they meet at a sharp corner edge (`turn`): in a mitre (the
|
||||||
|
/// default) or swept round the edge.
|
||||||
|
const FILLET_TURNS: [(&str, &str); 2] = [("ui.fillet_turn_mitre", "mitre"), ("ui.fillet_turn_swept", "swept")];
|
||||||
|
|
||||||
/// Which fillet options apply together, exactly as `solid.fillet` takes them (`fillet` in
|
/// Which fillet options apply together, exactly as `solid.fillet` takes them (`fillet` in
|
||||||
/// crates/engine/src/cmd/features.rs):
|
/// crates/engine/src/cmd/features.rs):
|
||||||
///
|
///
|
||||||
/// | Type | Face limits (`fit`) | Corners (`corner`) | Setback (`setbacks`) |
|
/// | Type | Face limits (`fit`) | Corners (`corner`) | Setback (`setbacks`) | Turns (`turn`) |
|
||||||
/// |---------------|-------------------------|-------------------------------------|----------------------|
|
/// |---------------|-------------------------|-------------------------------------|----------------------|---------------------------------|
|
||||||
/// | Constant | exact, reduce or taper | tangent; curvature with exact only | with curvature only |
|
/// | Constant | exact, reduce or taper | tangent; curvature with exact only | with curvature only | mitre; swept without taper, g2 |
|
||||||
/// | any other | exact (not sent) | tangent (not sent) | none |
|
/// | any other | exact (not sent) | tangent (not sent) | none | mitre (not sent) |
|
||||||
///
|
///
|
||||||
/// The dialog shows an option's row only where it applies and sends the option only then, and a
|
/// The dialog shows an option's row only where it applies and sends the option only then, and a
|
||||||
/// choice that rules another out puts that one back to its default ([`settle_fillet_options`]).
|
/// choice that rules another out puts that one back to its default ([`settle_fillet_options`]).
|
||||||
@@ -150,6 +154,8 @@ pub(super) fn fillet_option_applies(extra: &serde_json::Map<String, Value>, key:
|
|||||||
match key {
|
match key {
|
||||||
"fit" => constant,
|
"fit" => constant,
|
||||||
"corner" | "setbacks" => constant && exact,
|
"corner" | "setbacks" => constant && exact,
|
||||||
|
// A swept turn takes an exact or reduced radius and rolling-ball corners.
|
||||||
|
"turn" => constant && extra.get("fit").and_then(Value::as_str) != Some("taper") && extra.get("corner").and_then(Value::as_str) != Some("g2"),
|
||||||
_ => true,
|
_ => true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -159,15 +165,29 @@ pub(super) fn fillet_option_applies(extra: &serde_json::Map<String, Value>, key:
|
|||||||
/// takes the exact radius. A type other than Constant clears both ([`choose_fillet_type`]).
|
/// takes the exact radius. A type other than Constant clears both ([`choose_fillet_type`]).
|
||||||
fn settle_fillet_options(extra: &mut serde_json::Map<String, Value>, changed: &str, setback: &mut Option<String>) {
|
fn settle_fillet_options(extra: &mut serde_json::Map<String, Value>, changed: &str, setback: &mut Option<String>) {
|
||||||
let fitted = extra.get("fit").and_then(Value::as_str).is_some_and(|fit| fit != "exact");
|
let fitted = extra.get("fit").and_then(Value::as_str).is_some_and(|fit| fit != "exact");
|
||||||
|
let tapered = extra.get("fit").and_then(Value::as_str) == Some("taper");
|
||||||
let patch = extra.get("corner").and_then(Value::as_str) == Some("g2");
|
let patch = extra.get("corner").and_then(Value::as_str) == Some("g2");
|
||||||
|
let swept = extra.get("turn").and_then(Value::as_str) == Some("swept");
|
||||||
match changed {
|
match changed {
|
||||||
"fit" if fitted => {
|
"fit" if fitted => {
|
||||||
extra.remove("corner");
|
extra.remove("corner");
|
||||||
extra.remove("setbacks");
|
extra.remove("setbacks");
|
||||||
*setback = None;
|
*setback = None;
|
||||||
|
if tapered {
|
||||||
|
extra.remove("turn");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"corner" if patch => {
|
"corner" if patch => {
|
||||||
extra.remove("fit");
|
extra.remove("fit");
|
||||||
|
extra.remove("turn");
|
||||||
|
}
|
||||||
|
"turn" if swept => {
|
||||||
|
extra.remove("corner");
|
||||||
|
extra.remove("setbacks");
|
||||||
|
*setback = None;
|
||||||
|
if tapered {
|
||||||
|
extra.remove("fit");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -218,7 +238,7 @@ fn fillet_corner_rows(
|
|||||||
let hint = crate::i18n::format("ui.fillet_setback_hint", &[("factor", &factor)]);
|
let hint = crate::i18n::format("ui.fillet_setback_hint", &[("factor", &factor)]);
|
||||||
ui_kit::checked_row(ui, "ui.fillet_setback", &mut text, &hint, warning.as_deref())
|
ui_kit::checked_row(ui, "ui.fillet_setback", &mut text, &hint, warning.as_deref())
|
||||||
});
|
});
|
||||||
row.response.on_hover_text(crate::i18n::format("ui.fillet_setback_tip", &[("factor", &factor)]));
|
row.response.backing_on_hover_text(crate::i18n::format("ui.fillet_setback_tip", &[("factor", &factor)]));
|
||||||
let (accepted, field) = row.inner;
|
let (accepted, field) = row.inner;
|
||||||
enter |= accepted;
|
enter |= accepted;
|
||||||
if text != shown {
|
if text != shown {
|
||||||
@@ -237,7 +257,7 @@ fn fillet_fit_rows(app: &SolveApp, ui: &mut egui::Ui, extra: &mut serde_json::Ma
|
|||||||
let t = Tokens::get();
|
let t = Tokens::get();
|
||||||
let before = extra.get("fit").cloned();
|
let before = extra.get("fit").cloned();
|
||||||
let row = ui.scope(|ui| form_row(ui, "ui.fillet_fit", |ui| extra_choice(ui, extra, "fi_fit", "fit", &FILLET_FITS)));
|
let row = ui.scope(|ui| form_row(ui, "ui.fillet_fit", |ui| extra_choice(ui, extra, "fi_fit", "fit", &FILLET_FITS)));
|
||||||
row.response.on_hover_text(tr("ui.fillet_fit_tip"));
|
row.response.backing_on_hover_text(tr("ui.fillet_fit_tip"));
|
||||||
let fit = row.inner;
|
let fit = row.inner;
|
||||||
if extra.get("fit") != before.as_ref() {
|
if extra.get("fit") != before.as_ref() {
|
||||||
settle_fillet_options(extra, "fit", setback);
|
settle_fillet_options(extra, "fit", setback);
|
||||||
@@ -256,6 +276,17 @@ fn fillet_fit_rows(app: &SolveApp, ui: &mut egui::Ui, extra: &mut serde_json::Ma
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A Constant radius fillet's Turns row (`turn`): how two rounds turn where they meet at a sharp
|
||||||
|
/// corner edge.
|
||||||
|
fn fillet_turn_row(ui: &mut egui::Ui, extra: &mut serde_json::Map<String, Value>, setback: &mut Option<String>) {
|
||||||
|
let before = extra.get("turn").cloned();
|
||||||
|
let row = ui.scope(|ui| form_row(ui, "ui.fillet_turn", |ui| extra_choice(ui, extra, "fi_turn", "turn", &FILLET_TURNS)));
|
||||||
|
row.response.on_hover_text(tr("ui.fillet_turn_tip"));
|
||||||
|
if extra.get("turn") != before.as_ref() {
|
||||||
|
settle_fillet_options(extra, "turn", setback);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The most instances a solid pattern makes in one direction (the document's own limit).
|
/// The most instances a solid pattern makes in one direction (the document's own limit).
|
||||||
const MAX_PATTERN_INSTANCES: i64 = 1000;
|
const MAX_PATTERN_INSTANCES: i64 = 1000;
|
||||||
|
|
||||||
@@ -293,6 +324,7 @@ fn choose_fillet_type(extra: &mut serde_json::Map<String, Value>, index: usize,
|
|||||||
extra.remove("fit");
|
extra.remove("fit");
|
||||||
extra.remove("corner");
|
extra.remove("corner");
|
||||||
extra.remove("setbacks");
|
extra.remove("setbacks");
|
||||||
|
extra.remove("turn");
|
||||||
}
|
}
|
||||||
if index == 2 {
|
if index == 2 {
|
||||||
extra.entry("radius2").or_insert_with(|| json!("1 mm"));
|
extra.entry("radius2").or_insert_with(|| json!("1 mm"));
|
||||||
@@ -322,14 +354,14 @@ pub(super) fn rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui:
|
|||||||
};
|
};
|
||||||
tr(key).to_owned()
|
tr(key).to_owned()
|
||||||
};
|
};
|
||||||
ui_kit::text(ui, &prompt, ui_kit::Text::Hint("tool")).on_hover_text(&prompt);
|
ui_kit::text(ui, &prompt, ui_kit::Text::Hint("tool")).backing_on_hover_text(&prompt);
|
||||||
if matches!(&d.kind, Kind::SketchModify(k) if k.previews()) {
|
if matches!(&d.kind, Kind::SketchModify(k) if k.previews()) {
|
||||||
let label = d.active_input().map(|input| tr(input.label)).unwrap_or_else(|| tr("Objects"));
|
let label = d.active_input().map(|input| tr(input.label)).unwrap_or_else(|| tr("Objects"));
|
||||||
let step = crate::i18n::format(
|
let step = crate::i18n::format(
|
||||||
"dialog.selection_step",
|
"dialog.selection_step",
|
||||||
&[("step", &(d.active + 1).to_string()), ("total", &d.inputs.len().to_string()), ("input", label)],
|
&[("step", &(d.active + 1).to_string()), ("total", &d.inputs.len().to_string()), ("input", label)],
|
||||||
);
|
);
|
||||||
ui_kit::text(ui, &step, ui_kit::Text::Hint("step")).on_hover_text(tr("dialog.selection_guidance"));
|
ui_kit::text(ui, &step, ui_kit::Text::Hint("step")).backing_on_hover_text(tr("dialog.selection_guidance"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let manage_hint = match &d.kind {
|
let manage_hint = match &d.kind {
|
||||||
@@ -540,10 +572,11 @@ pub(super) fn rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui:
|
|||||||
}
|
}
|
||||||
response
|
response
|
||||||
})
|
})
|
||||||
.on_hover_text(tr("ui.fillet_type_choices_tip"));
|
.backing_on_hover_text(tr("ui.fillet_type_choices_tip"));
|
||||||
if rtype == 0 {
|
if rtype == 0 {
|
||||||
enter |= fillet_corner_rows(ui, &app.session, &mut d.extra, &d.inputs, radius, setback);
|
enter |= fillet_corner_rows(ui, &app.session, &mut d.extra, &d.inputs, radius, setback);
|
||||||
fillet_fit_rows(app, ui, &mut d.extra, setback);
|
fillet_fit_rows(app, ui, &mut d.extra, setback);
|
||||||
|
fillet_turn_row(ui, &mut d.extra, setback);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if *chamfer && *ctype == 1 {
|
if *chamfer && *ctype == 1 {
|
||||||
@@ -558,7 +591,7 @@ pub(super) fn rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui:
|
|||||||
ui_kit::section(ui, "fillet_options", "Options", true, |ui| {
|
ui_kit::section(ui, "fillet_options", "Options", true, |ui| {
|
||||||
ui.scope(|ui| ui_kit::checkbox_grid(ui, &mut [("Tangent Chain", chain), ("Preview", &mut d.preview_enabled)], 2))
|
ui.scope(|ui| ui_kit::checkbox_grid(ui, &mut [("Tangent Chain", chain), ("Preview", &mut d.preview_enabled)], 2))
|
||||||
.response
|
.response
|
||||||
.on_hover_text(tr("ui.fillet_options_tip"));
|
.backing_on_hover_text(tr("ui.fillet_options_tip"));
|
||||||
});
|
});
|
||||||
// The radii along the edge, for the two types that take them.
|
// The radii along the edge, for the two types that take them.
|
||||||
if !*chamfer && matches!(rtype, 2 | 4) {
|
if !*chamfer && matches!(rtype, 2 | 4) {
|
||||||
@@ -579,12 +612,12 @@ pub(super) fn rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui:
|
|||||||
form_row(ui, "Mid Radius", |ui| {
|
form_row(ui, "Mid Radius", |ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui_kit::fraction_field(ui, position)
|
ui_kit::fraction_field(ui, position)
|
||||||
.on_hover_text(tr("Position along the edge, 0 at the start end, 1 at the other"));
|
.backing_on_hover_text(tr("Position along the edge, 0 at the start end, 1 at the other"));
|
||||||
let width = (ui.available_width() - t.space_24 - t.space_4).max(0.0);
|
let width = (ui.available_width() - t.space_24 - t.space_4).max(0.0);
|
||||||
ui.allocate_ui_with_layout(vec2(width, t.space_24), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
ui.allocate_ui_with_layout(vec2(width, t.space_24), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||||
enter |= field(ui, r);
|
enter |= field(ui, r);
|
||||||
});
|
});
|
||||||
if ui_kit::button(ui, "×", ui_kit::Button::Small).on_hover_text(tr("Remove this point")).clicked() {
|
if ui_kit::button(ui, "×", ui_kit::Button::Small).backing_on_hover_text(tr("Remove this point")).clicked() {
|
||||||
remove = Some(k);
|
remove = Some(k);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1006,7 +1039,7 @@ fn additional_rows(app: &mut SolveApp, d: &mut Dialog, ui: &mut egui::Ui) -> boo
|
|||||||
ui.label(format!("{:.2}, {:.2}, {:.2}", p.x, p.y, p.z));
|
ui.label(format!("{:.2}, {:.2}, {:.2}", p.x, p.y, p.z));
|
||||||
// A cylinder's place is its Face pick, cleared there.
|
// A cylinder's place is its Face pick, cleared there.
|
||||||
if *cmd != "solid.cylinder"
|
if *cmd != "solid.cylinder"
|
||||||
&& ui_kit::button(ui, "×", ui_kit::Button::Small).on_hover_text(tr("Place at the origin instead")).clicked()
|
&& ui_kit::button(ui, "×", ui_kit::Button::Small).backing_on_hover_text(tr("Place at the origin instead")).clicked()
|
||||||
{
|
{
|
||||||
*at = None;
|
*at = None;
|
||||||
}
|
}
|
||||||
@@ -1281,13 +1314,46 @@ mod tests {
|
|||||||
assert!(scenario::handle_at("dialog:ui.fillet_fit").is_none(), "a fitted radius is for Constant radius only");
|
assert!(scenario::handle_at("dialog:ui.fillet_fit").is_none(), "a fitted radius is for Constant radius only");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Turns sends `turn: swept` with a Constant radius only, and a choice it rules out (a
|
||||||
|
/// narrowed radius, a curvature corner) puts the other back: Swept and those never go
|
||||||
|
/// together.
|
||||||
|
#[test]
|
||||||
|
fn turns_maps_to_turn_and_rules_out_what_a_swept_corner_does_not_take() {
|
||||||
|
let mut h = fillet_harness();
|
||||||
|
set_edges(&mut h, 3);
|
||||||
|
assert!(scenario::handle_at("dialog:ui.fillet_turn").is_some());
|
||||||
|
assert!(parameters(&h).get("turn").is_none(), "Mitre is the default, not sent");
|
||||||
|
choose(&mut h, "ui.fillet_turn", "ui.fillet_turn_swept");
|
||||||
|
assert_eq!(parameters(&h)["turn"], json!("swept"));
|
||||||
|
choose(&mut h, "ui.fillet_fit", "ui.fillet_fit_reduce");
|
||||||
|
assert_eq!((parameters(&h)["turn"].clone(), parameters(&h)["fit"].clone()), (json!("swept"), json!("reduce")));
|
||||||
|
choose(&mut h, "ui.fillet_fit", "ui.fillet_fit_taper");
|
||||||
|
assert!(parameters(&h).get("turn").is_none(), "a narrowed radius puts Turns back to Mitre");
|
||||||
|
choose(&mut h, "ui.fillet_turn", "ui.fillet_turn_swept");
|
||||||
|
assert!(parameters(&h).get("fit").is_none(), "Swept puts Face limits back to Exact radius");
|
||||||
|
choose(&mut h, "ui.corners", Continuity::G2.label_id());
|
||||||
|
assert!(parameters(&h).get("turn").is_none(), "a curvature corner puts Turns back to Mitre");
|
||||||
|
choose(&mut h, "ui.fillet_turn", "ui.fillet_turn_swept");
|
||||||
|
assert!(parameters(&h).get("corner").is_none(), "Swept puts Corners back to Tangent");
|
||||||
|
choose(&mut h, "ui.fillet_turn", "ui.fillet_turn_mitre");
|
||||||
|
assert!(parameters(&h).get("turn").is_none(), "Mitre is not sent");
|
||||||
|
choose(&mut h, "ui.fillet_turn", "ui.fillet_turn_swept");
|
||||||
|
choose(&mut h, "Type", "Chord length");
|
||||||
|
assert!(parameters(&h).get("turn").is_none() && scenario::handle_at("dialog:ui.fillet_turn").is_none(), "Turns is for Constant radius only");
|
||||||
|
}
|
||||||
|
|
||||||
/// The dialog follows the option model (`fillet_option_applies`): through every type, fit and
|
/// The dialog follows the option model (`fillet_option_applies`): through every type, fit and
|
||||||
/// corner choice, in either order, each option it shows is one the others allow, and the
|
/// corner choice, in either order, each option it shows is one the others allow, and the
|
||||||
/// command it builds is never refused for its options (`solid.fillet` runs it on a copy of
|
/// command it builds is never refused for its options (`solid.fillet` runs it on a copy of
|
||||||
/// the design). A choice that rules another out resets that one, visibly.
|
/// the design). A choice that rules another out resets that one, visibly.
|
||||||
#[test]
|
#[test]
|
||||||
fn no_reachable_fillet_option_state_is_refused_for_its_options() {
|
fn no_reachable_fillet_option_state_is_refused_for_its_options() {
|
||||||
let refused_for_options = |e: &str| e.contains("take a constant radius") || e.contains("is built for") || e.contains("are built for");
|
let refused_for_options = |e: &str| {
|
||||||
|
e.contains("take a constant radius")
|
||||||
|
|| e.contains("is built for")
|
||||||
|
|| e.contains("are built for")
|
||||||
|
|| e.contains(solidcraft_engine::doc::SWEPT_TAKES)
|
||||||
|
};
|
||||||
// Each distinct command is run once.
|
// Each distinct command is run once.
|
||||||
let ran = std::cell::RefCell::new(std::collections::BTreeSet::new());
|
let ran = std::cell::RefCell::new(std::collections::BTreeSet::new());
|
||||||
let check = |h: &Harness, what: &str| {
|
let check = |h: &Harness, what: &str| {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
//! its middle goes, its size in millimetres or as a factor, and an angle) with a live preview,
|
//! its middle goes, its size in millimetres or as a factor, and an angle) with a live preview,
|
||||||
//! and OK adds it (#2605, #3443).
|
//! and OK adds it (#2605, #3443).
|
||||||
|
|
||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
use crate::SolveApp;
|
use crate::SolveApp;
|
||||||
@@ -46,7 +47,7 @@ pub fn title(p: &Place) -> &'static str {
|
|||||||
pub fn rows(ui: &mut egui::Ui, p: &mut Place) {
|
pub fn rows(ui: &mut egui::Ui, p: &mut Place) {
|
||||||
let name = std::path::Path::new(&p.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.path.clone());
|
let name = std::path::Path::new(&p.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| p.path.clone());
|
||||||
form_row(ui, "File", |ui| {
|
form_row(ui, "File", |ui| {
|
||||||
ui.label(name).on_hover_text(&p.path);
|
ui.label(name).backing_on_hover_text(&p.path);
|
||||||
});
|
});
|
||||||
for (label, v, handle) in [("X", &mut p.x, "dialog:insert:x"), ("Y", &mut p.y, "dialog:insert:y")] {
|
for (label, v, handle) in [("X", &mut p.x, "dialog:insert:x"), ("Y", &mut p.y, "dialog:insert:y")] {
|
||||||
form_row(ui, label, |ui| {
|
form_row(ui, label, |ui| {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
//! The first local appearance-library consumer, inside the existing Appearance panel.
|
//! The first local appearance-library consumer, inside the existing Appearance panel.
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
|
||||||
use crate::dialogs_appearance::selected_params;
|
use crate::dialogs_appearance::selected_params;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
use crate::{SolveApp, dialogs_appearance::Ap, selection::SelInput, ui_kit};
|
use crate::{SolveApp, dialogs_appearance::Ap, selection::SelInput, ui_kit};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use solidcraft_engine::io::catalog::Catalog;
|
use solidcraft_engine::io::catalog::Catalog;
|
||||||
@@ -221,7 +222,7 @@ fn import_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &[SelI
|
|||||||
}
|
}
|
||||||
let button =
|
let button =
|
||||||
ui_kit::list_picker(ui, &entry.id, entry.definition.name(), k.local.selected.as_deref() == Some(entry.id.as_str()))
|
ui_kit::list_picker(ui, &entry.id, entry.definition.name(), k.local.selected.as_deref() == Some(entry.id.as_str()))
|
||||||
.on_hover_text(format!(
|
.backing_on_hover_text(format!(
|
||||||
"{} · {} · {}",
|
"{} · {} · {}",
|
||||||
entry.id,
|
entry.id,
|
||||||
entry.provenance.license,
|
entry.provenance.license,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
//! show while open (a study's pose, an explosion) is display only: pending occurrence moves,
|
//! show while open (a study's pose, an explosion) is display only: pending occurrence moves,
|
||||||
//! computed on a scratch copy of the design and put back when the dialog closes.
|
//! computed on a scratch copy of the design and put back when the dialog closes.
|
||||||
|
|
||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
use crate::ui_kit::{choice_row, expr_row, text_row};
|
use crate::ui_kit::{choice_row, expr_row, text_row};
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
@@ -205,7 +206,7 @@ fn transport(ui: &mut egui::Ui, what: Transport, tip: &str) -> egui::Response {
|
|||||||
p.add(bar(c.x + 6.0));
|
p.add(bar(c.x + 6.0));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
resp.on_hover_text(crate::i18n::tr_label(tip))
|
resp.backing_on_hover_text(crate::i18n::tr_label(tip))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Study `key` as the Motion Study dialog edits it: (id, name, steps, keys as tracks with values
|
/// Study `key` as the Motion Study dialog edits it: (id, name, steps, keys as tracks with values
|
||||||
@@ -473,7 +474,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs: &mut [Sel
|
|||||||
let _ = app.run("contact.edit", json!({"set": n, "suppressed": !off}));
|
let _ = app.run("contact.edit", json!({"set": n, "suppressed": !off}));
|
||||||
}
|
}
|
||||||
if crate::ui_kit::button(ui, crate::i18n::tr_label("×"), crate::ui_kit::Button::Small)
|
if crate::ui_kit::button(ui, crate::i18n::tr_label("×"), crate::ui_kit::Button::Small)
|
||||||
.on_hover_text(crate::i18n::tr_label("Delete the set"))
|
.backing_on_hover_text(crate::i18n::tr_label("Delete the set"))
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
let _ = app.run("contact.edit", json!({"set": n, "delete": true}));
|
let _ = app.run("contact.edit", json!({"set": n, "delete": true}));
|
||||||
@@ -641,7 +642,7 @@ fn motion_study_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs:
|
|||||||
let width = (ui.available_width() - t.control_height - t.space_8).max(0.0);
|
let width = (ui.available_width() - t.control_height - t.space_8).max(0.0);
|
||||||
ui.allocate_ui(vec2(width, t.control_height), |ui| combo(ui, &format!("ms_joint{ti}"), &refs, &mut pos));
|
ui.allocate_ui(vec2(width, t.control_height), |ui| combo(ui, &format!("ms_joint{ti}"), &refs, &mut pos));
|
||||||
if crate::ui_kit::button(ui, crate::i18n::tr_label("×"), crate::ui_kit::Button::Small)
|
if crate::ui_kit::button(ui, crate::i18n::tr_label("×"), crate::ui_kit::Button::Small)
|
||||||
.on_hover_text(crate::i18n::tr_label("Remove the track"))
|
.backing_on_hover_text(crate::i18n::tr_label("Remove the track"))
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
remove = Some(ti);
|
remove = Some(ti);
|
||||||
@@ -671,7 +672,7 @@ fn motion_study_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs:
|
|||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if crate::ui_kit::button(ui, crate::i18n::tr_label("Key here"), crate::ui_kit::Button::Small)
|
if crate::ui_kit::button(ui, crate::i18n::tr_label("Key here"), crate::ui_kit::Button::Small)
|
||||||
.on_hover_text(crate::i18n::tr_label("Add a key at this step with the joint's value now"))
|
.backing_on_hover_text(crate::i18n::tr_label("Add a key at this step with the joint's value now"))
|
||||||
.clicked()
|
.clicked()
|
||||||
&& !tr.points.iter().any(|(s, _)| *s == now)
|
&& !tr.points.iter().any(|(s, _)| *s == now)
|
||||||
{
|
{
|
||||||
@@ -699,7 +700,7 @@ fn motion_study_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs:
|
|||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
if crate::ui_kit::button(ui, crate::i18n::tr_label("Add Key"), crate::ui_kit::Button::Normal)
|
if crate::ui_kit::button(ui, crate::i18n::tr_label("Add Key"), crate::ui_kit::Button::Normal)
|
||||||
.on_hover_text(crate::i18n::tr_label("Key a joint's value at this step"))
|
.backing_on_hover_text(crate::i18n::tr_label("Key a joint's value at this step"))
|
||||||
.clicked()
|
.clicked()
|
||||||
&& let Some((ji, _, kind)) = moving.first()
|
&& let Some((ji, _, kind)) = moving.first()
|
||||||
{
|
{
|
||||||
@@ -707,7 +708,7 @@ fn motion_study_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Mo, inputs:
|
|||||||
tracks.push(Track { joint: *ji, index: 0, points: vec![(now, format!("0 {unit}"))] });
|
tracks.push(Track { joint: *ji, index: 0, points: vec![(now, format!("0 {unit}"))] });
|
||||||
}
|
}
|
||||||
export = crate::ui_kit::button(ui, crate::i18n::tr_label("Export Animation…"), crate::ui_kit::Button::Normal)
|
export = crate::ui_kit::button(ui, crate::i18n::tr_label("Export Animation…"), crate::ui_kit::Button::Normal)
|
||||||
.on_hover_text(crate::i18n::tr_label("Save the study as an animated GIF, seen as the viewport shows it"))
|
.backing_on_hover_text(crate::i18n::tr_label("Save the study as an animated GIF, seen as the viewport shows it"))
|
||||||
.clicked();
|
.clicked();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
//! over a program the user added removes it.
|
//! over a program the user added removes it.
|
||||||
|
|
||||||
use crate::selection::SelectionExt as _;
|
use crate::selection::SelectionExt as _;
|
||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
use crate::ui_kit::choice_row;
|
use crate::ui_kit::choice_row;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@@ -450,12 +451,12 @@ fn slicer_combo(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut SaveMesh) {
|
|||||||
crate::scenario::publish_handle(&format!("dialog:Slicer:{}", e.slicer.name), rect.center());
|
crate::scenario::publish_handle(&format!("dialog:Slicer:{}", e.slicer.name), rect.center());
|
||||||
if e.user {
|
if e.user {
|
||||||
let hint = crate::i18n::format("ui.slicer_delete_tip", &[("program", e.slicer.command.first().map_or("", String::as_str))]);
|
let hint = crate::i18n::format("ui.slicer_delete_tip", &[("program", e.slicer.command.first().map_or("", String::as_str))]);
|
||||||
resp.clone().on_hover_text(if elided { format!("{}\n{hint}", e.slicer.name) } else { hint });
|
resp.clone().backing_on_hover_text(if elided { format!("{}\n{hint}", e.slicer.name) } else { hint });
|
||||||
if resp.hovered() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Delete)) {
|
if resp.hovered() && ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Delete)) {
|
||||||
delete = Some(e.key.clone());
|
delete = Some(e.key.clone());
|
||||||
}
|
}
|
||||||
} else if elided {
|
} else if elided {
|
||||||
resp.clone().on_hover_text(&e.slicer.name);
|
resp.clone().backing_on_hover_text(&e.slicer.name);
|
||||||
}
|
}
|
||||||
if resp.clicked() {
|
if resp.clicked() {
|
||||||
chosen = Some(e.key.clone());
|
chosen = Some(e.key.clone());
|
||||||
@@ -495,14 +496,14 @@ fn slicer_combo(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut SaveMesh) {
|
|||||||
Tokens::get().text,
|
Tokens::get().text,
|
||||||
);
|
);
|
||||||
crate::scenario::publish_handle("dialog:Slicer:Custom", rect.center());
|
crate::scenario::publish_handle("dialog:Slicer:Custom", rect.center());
|
||||||
if resp.on_hover_text(crate::i18n::tr("ui.custom_program_tip")).clicked() {
|
if resp.backing_on_hover_text(crate::i18n::tr("ui.custom_program_tip")).clicked() {
|
||||||
pick_custom = true;
|
pick_custom = true;
|
||||||
ui.close();
|
ui.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ui.style_mut().wrap_mode = wrap;
|
ui.style_mut().wrap_mode = wrap;
|
||||||
if let Some(name) = name {
|
if let Some(name) = name {
|
||||||
r.clone().on_hover_text(name);
|
r.clone().backing_on_hover_text(name);
|
||||||
}
|
}
|
||||||
if let Some(e) = current {
|
if let Some(e) = current {
|
||||||
let tex = icon(&mut app.slicer_menu, &ctx, e);
|
let tex = icon(&mut app.slicer_menu, &ctx, e);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
//! its bend lines, the bend table and DXF export).
|
//! its bend lines, the bend table and DXF export).
|
||||||
|
|
||||||
use crate::dialogs::put;
|
use crate::dialogs::put;
|
||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
use crate::ui_kit::{choice_row, expr_row, hint_row};
|
use crate::ui_kit::{choice_row, expr_row, hint_row};
|
||||||
use egui::{Align2, Color32, FontId, Pos2, Rect, RichText, Stroke, pos2, vec2};
|
use egui::{Align2, Color32, FontId, Pos2, Rect, RichText, Stroke, pos2, vec2};
|
||||||
use serde_json::{Map, Value, json};
|
use serde_json::{Map, Value, json};
|
||||||
@@ -261,7 +262,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel
|
|||||||
// Further bends: each a flange on the far edge of the one before it.
|
// Further bends: each a flange on the far edge of the one before it.
|
||||||
form_row(ui, "Further Bends", |ui| {
|
form_row(ui, "Further Bends", |ui| {
|
||||||
let add = crate::ui_kit::button(ui, "Add bend", crate::ui_kit::Button::Normal)
|
let add = crate::ui_kit::button(ui, "Add bend", crate::ui_kit::Button::Normal)
|
||||||
.on_hover_text(crate::i18n::tr_label("Another bend on the far edge of the last flange"));
|
.backing_on_hover_text(crate::i18n::tr_label("Another bend on the far edge of the last flange"));
|
||||||
if add.clicked() && then.len() < 16 {
|
if add.clicked() && then.len() < 16 {
|
||||||
then.push(("45 deg".into(), "20 mm".into(), false));
|
then.push(("45 deg".into(), "20 mm".into(), false));
|
||||||
}
|
}
|
||||||
@@ -296,7 +297,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Sm, inputs: &mut [Sel
|
|||||||
if points.len() > 1 || own.iter().any(|o| !(o.1.trim().is_empty() && o.2.trim().is_empty())) {
|
if points.len() > 1 || own.iter().any(|o| !(o.1.trim().is_empty() && o.2.trim().is_empty())) {
|
||||||
form_row(ui, "Per Edge", |ui| {
|
form_row(ui, "Per Edge", |ui| {
|
||||||
ui.label(RichText::new(crate::i18n::tr_label("angle, height")).weak())
|
ui.label(RichText::new(crate::i18n::tr_label("angle, height")).weak())
|
||||||
.on_hover_text("An edge's own angle and height. Empty: the values above.");
|
.backing_on_hover_text("An edge's own angle and height. Empty: the values above.");
|
||||||
});
|
});
|
||||||
for (n, p) in points.iter().enumerate() {
|
for (n, p) in points.iter().enumerate() {
|
||||||
let slot = own_slot(own, *p);
|
let slot = own_slot(own, *p);
|
||||||
|
|||||||
@@ -406,6 +406,14 @@ fn picked_list_removes_one_item_and_field_still_activates_input() {
|
|||||||
d.inputs[0].items.push(Sel::SketchCurve { id: second.clone(), sketch: None });
|
d.inputs[0].items.push(Sel::SketchCurve { id: second.clone(), sketch: None });
|
||||||
h.app.dialog = Some(d);
|
h.app.dialog = Some(d);
|
||||||
h.frames(4);
|
h.frames(4);
|
||||||
|
assert!(crate::scenario::handle_at("dialog:list:Entities:0:remove").is_none(), "the list starts folded, as in every dialog");
|
||||||
|
h.step(&json!({"click":{"handle":"dialog:list_toggle:Entities"}})).unwrap();
|
||||||
|
h.frames(2);
|
||||||
|
// Hovering a row lights its curve in the sketch.
|
||||||
|
let row = crate::scenario::handle_at("dialog:list:Entities:1:remove").unwrap();
|
||||||
|
h.call("ui.move", json!({"x": row.x, "y": row.y}));
|
||||||
|
h.frames(2);
|
||||||
|
assert!(matches!(&h.app.viewport.hover, Some(crate::viewport::Hit::SketchCurve { id, .. }) if *id == second), "{:?}", h.app.viewport.hover);
|
||||||
h.step(&json!({"click":{"handle":"dialog:list:Entities:0:remove"}})).unwrap();
|
h.step(&json!({"click":{"handle":"dialog:list:Entities:0:remove"}})).unwrap();
|
||||||
assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items, vec![Sel::SketchCurve { id: second, sketch: None }]);
|
assert_eq!(h.app.dialog.as_ref().unwrap().inputs[0].items, vec![Sel::SketchCurve { id: second, sketch: None }]);
|
||||||
h.step(&json!({"click":{"handle":"dialog:Base point"}})).unwrap();
|
h.step(&json!({"click":{"handle":"dialog:Base point"}})).unwrap();
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
//! Everything acts through commands: `sketch.dimension_text`, `sketch.delete`,
|
//! Everything acts through commands: `sketch.dimension_text`, `sketch.delete`,
|
||||||
//! `sketch.toggle_driven`, `parameters.change`.
|
//! `sketch.toggle_driven`, `parameters.change`.
|
||||||
|
|
||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
use crate::ui_kit::{Container, Surface};
|
use crate::ui_kit::{Container, Surface};
|
||||||
use solidcraft_engine::geom::prelude::*;
|
use solidcraft_engine::geom::prelude::*;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
@@ -1014,11 +1015,12 @@ fn edit_box(app: &mut SolveApp, ctx: &egui::Context) {
|
|||||||
let anchor = edit_anchor(at, e.live.is_some(), drawable, popup_width);
|
let anchor = edit_anchor(at, e.live.is_some(), drawable, popup_width);
|
||||||
// Background: under Preferences and Report a bug (#9016), like canvas value boxes.
|
// Background: under Preferences and Report a bug (#9016), like canvas value boxes.
|
||||||
Container::new(ctx, Surface::Canvas).area(egui::Id::new("sc_dim_edit_area")).fixed_pos(anchor).order(egui::Order::Background).show(ctx, |ui| {
|
Container::new(ctx, Surface::Canvas).area(egui::Id::new("sc_dim_edit_area")).fixed_pos(anchor).order(egui::Order::Background).show(ctx, |ui| {
|
||||||
Container::popup_frame(ui.style()).fill(Tokens::get().dialog_body).inner_margin(Tokens::get().space_4).show(ui, |ui| {
|
let frame = Container::popup_frame(ui.style()).fill(Tokens::get().dialog_body).inner_margin(Tokens::get().space_4);
|
||||||
|
Container::show_backing(ui, frame, |ui| {
|
||||||
crate::ui_kit::scope(ui, |ui| {
|
crate::ui_kit::scope(ui, |ui| {
|
||||||
ui.set_max_width(popup_width);
|
ui.set_max_width(popup_width);
|
||||||
crate::ui_kit::text(ui, &crate::i18n::format("dialog.dimension_name", &[("name", &e.param)]), crate::ui_kit::Text::Inline)
|
crate::ui_kit::text(ui, &crate::i18n::format("dialog.dimension_name", &[("name", &e.param)]), crate::ui_kit::Text::Inline)
|
||||||
.on_hover_text(crate::i18n::tr("dialog.dimension_insert_hint"));
|
.backing_on_hover_text(crate::i18n::tr("dialog.dimension_insert_hint"));
|
||||||
if !e.owned {
|
if !e.owned {
|
||||||
// The whole value is selected before the field reads its state, every frame
|
// The whole value is selected before the field reads its state, every frame
|
||||||
// until the person takes it over, so typing replaces it (#11204). Only on the
|
// until the person takes it over, so typing replaces it (#11204). Only on the
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
//! Closing a design with unsaved changes asks first, and so does quitting: one "save changes?"
|
//! Closing a design with unsaved changes asks first, and so does quitting: one "save changes?"
|
||||||
//! per design with unsaved changes ([`request_quit`]).
|
//! per design with unsaved changes ([`request_quit`]).
|
||||||
|
|
||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
use crate::ui_kit::{Container, Surface};
|
use crate::ui_kit::{Container, Surface};
|
||||||
use egui::{FontId, Rect, Sense, pos2, vec2};
|
use egui::{FontId, Rect, Sense, pos2, vec2};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -947,9 +948,9 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
|
|||||||
if resp.secondary_clicked() {
|
if resp.secondary_clicked() {
|
||||||
menu = Some((i, ui.ctx().pointer_interact_pos().unwrap_or(tr.left_bottom())));
|
menu = Some((i, ui.ctx().pointer_interact_pos().unwrap_or(tr.left_bottom())));
|
||||||
}
|
}
|
||||||
if xresp.on_hover_text("Close").clicked() {
|
if xresp.backing_on_hover_text("Close").clicked() {
|
||||||
shut = Some(i);
|
shut = Some(i);
|
||||||
} else if (if held.is_some() { resp } else { resp.on_hover_text(tip) }).clicked() {
|
} else if (if held.is_some() { resp } else { resp.backing_on_hover_text(tip) }).clicked() {
|
||||||
pick = Some(i);
|
pick = Some(i);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -976,7 +977,7 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
|
|||||||
t.app_bar_text,
|
t.app_bar_text,
|
||||||
);
|
);
|
||||||
let id = egui::Id::new("sc_doc_more");
|
let id = egui::Id::new("sc_doc_more");
|
||||||
if mresp.clone().on_hover_text(crate::i18n::tr_label("All open designs")).clicked() {
|
if mresp.clone().backing_on_hover_text(crate::i18n::tr_label("All open designs")).clicked() {
|
||||||
ui.ctx().data_mut(|d| d.insert_temp(id, !d.get_temp::<bool>(id).unwrap_or(false)));
|
ui.ctx().data_mut(|d| d.insert_temp(id, !d.get_temp::<bool>(id).unwrap_or(false)));
|
||||||
}
|
}
|
||||||
if ui.ctx().data(|d| d.get_temp::<bool>(id).unwrap_or(false)) {
|
if ui.ctx().data(|d| d.get_temp::<bool>(id).unwrap_or(false)) {
|
||||||
@@ -1058,7 +1059,7 @@ pub fn tabs(app: &mut SolveApp, ui: &mut egui::Ui, r: Rect, x0: f32, x1: f32) {
|
|||||||
// A file loading into the window keeps the tabs as they are (dragging still moves it).
|
// A file loading into the window keeps the tabs as they are (dragging still moves it).
|
||||||
let idle = !app.file_load.busy();
|
let idle = !app.file_load.busy();
|
||||||
crate::scenario::publish_handle("tab:new", pr.center());
|
crate::scenario::publish_handle("tab:new", pr.center());
|
||||||
if presp.on_hover_text(app.tr("ui.start_page")).clicked() && idle {
|
if presp.backing_on_hover_text(app.tr("ui.start_page")).clicked() && idle {
|
||||||
app.home.show(app.session.revision);
|
app.home.show(app.session.revision);
|
||||||
}
|
}
|
||||||
match (shut, pick) {
|
match (shut, pick) {
|
||||||
|
|||||||
@@ -1227,7 +1227,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
|||||||
Container::new(ctx, Surface::InputShield).area(id).order(egui::Order::Middle).fixed_pos(cover.min).interactable(true).show(ctx, |ui| {
|
Container::new(ctx, Surface::InputShield).area(id).order(egui::Order::Middle).fixed_pos(cover.min).interactable(true).show(ctx, |ui| {
|
||||||
ui.set_min_size(cover.size());
|
ui.set_min_size(cover.size());
|
||||||
ui.interact(cover, egui::Id::new("file_loading_block"), egui::Sense::click_and_drag());
|
ui.interact(cover, egui::Id::new("file_loading_block"), egui::Sense::click_and_drag());
|
||||||
ui.painter().rect_filled(cover, 0.0, egui::Color32::from_black_alpha(70));
|
let shield = Container::new(ctx, Surface::InputShield);
|
||||||
|
shield.paint_background(ui.painter(), shield.frame(ctx, egui::Color32::from_black_alpha(70), crate::frame::Side::Left), cover);
|
||||||
});
|
});
|
||||||
// Anchored at its top: the card grows downwards (the saved preview) and Cancel stays put.
|
// Anchored at its top: the card grows downwards (the saved preview) and Cancel stays put.
|
||||||
let top = egui::pos2(centre.x, (centre.y - 120.0).max(cover.top() + 8.0));
|
let top = egui::pos2(centre.x, (centre.y - 120.0).max(cover.top() + 8.0));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
//! (#2548): a graph of the last frames' times with the triangles drawn over it, a histogram of
|
//! (#2548): 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 readout to show or hide the details.
|
//! frame times, and what the GPU drew. Click the readout to show or hide the details.
|
||||||
|
|
||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
|
|
||||||
use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, pos2, vec2};
|
use egui::{Align2, Color32, FontId, Rect, Sense, Stroke, pos2, vec2};
|
||||||
@@ -192,7 +193,7 @@ pub(crate) fn paint(app: &mut SolveApp, ui: &egui::Ui, painter: &egui::Painter,
|
|||||||
r = r.translate(vec2(0.0, nav.top() - 6.0 - r.bottom()));
|
r = r.translate(vec2(0.0, nav.top() - 6.0 - r.bottom()));
|
||||||
}
|
}
|
||||||
let id = egui::Id::new("sc_frame_rate");
|
let id = egui::Id::new("sc_frame_rate");
|
||||||
let clicked = ui.interact(r, id, Sense::click()).on_hover_text(tr("perf.click_for_details")).clicked();
|
let clicked = ui.interact(r, id, Sense::click()).backing_on_hover_text(tr("perf.click_for_details")).clicked();
|
||||||
if clicked {
|
if clicked {
|
||||||
app.preferences.frame_rate_details = !app.preferences.frame_rate_details;
|
app.preferences.frame_rate_details = !app.preferences.frame_rate_details;
|
||||||
}
|
}
|
||||||
|
|||||||
+312
-16
@@ -662,18 +662,122 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn blur_radius_schedule_and_hostile_values() {
|
fn blur_radius_schedule_and_hostile_values() {
|
||||||
use crate::blur::{schedule, validate_radius};
|
use crate::blur::{schedule, validate_radius};
|
||||||
for radius in [f32::NAN, f32::INFINITY, -1.0, 40.01] {
|
for radius in [f32::NAN, f32::INFINITY, -1.0, 60.01] {
|
||||||
assert!(validate_radius(radius).is_err());
|
assert!(validate_radius(radius).is_err());
|
||||||
assert!(schedule(radius).is_none());
|
assert!(schedule(radius).is_none());
|
||||||
}
|
}
|
||||||
assert!(schedule(0.0).is_none());
|
assert!(schedule(0.0).is_none());
|
||||||
for (radius, steps) in [(1.0, 2), (8.0, 2), (9.0, 3), (18.0, 3), (19.0, 4), (30.0, 4), (31.0, 5), (40.0, 5)] {
|
for (radius, steps) in [(1.0, 2), (8.0, 2), (9.0, 3), (18.0, 3), (19.0, 4), (30.0, 4), (31.0, 5), (40.0, 5), (60.0, 5)] {
|
||||||
let (actual, offset) = schedule(radius).unwrap();
|
let (actual, offset) = schedule(radius).unwrap();
|
||||||
assert_eq!(actual, steps);
|
assert_eq!(actual, steps);
|
||||||
assert!(offset.is_finite() && offset > 0.0);
|
assert!(offset.is_finite() && offset > 0.0);
|
||||||
let variance = (35.0 / 18.0) * (4.0_f32.powi(steps as i32) - 1.0);
|
let variance = (35.0 / 18.0) * (4.0_f32.powi(steps as i32) - 1.0);
|
||||||
assert!((offset * variance.sqrt() - radius / 2.0).abs() < 0.0001);
|
assert!((offset * variance.sqrt() - radius / 2.0).abs() < 0.0001);
|
||||||
}
|
}
|
||||||
|
for cap in 2..=5 {
|
||||||
|
let (steps, offset) = crate::blur::schedule_capped(40.0, cap).unwrap();
|
||||||
|
assert_eq!(steps, cap);
|
||||||
|
let variance = (35.0 / 18.0) * (4.0_f32.powi(steps as i32) - 1.0);
|
||||||
|
assert!((offset * variance.sqrt() - 20.0).abs() < 0.0001, "tier cap must preserve target radius");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blur_guard_threshold_idle_minimum_and_session_undo() {
|
||||||
|
let mut guard = crate::blur::Guard::default();
|
||||||
|
let radius = 40.0;
|
||||||
|
for _ in 0..59 {
|
||||||
|
guard.observe(radius, 23.0, true);
|
||||||
|
}
|
||||||
|
assert_eq!((guard.cap, guard.streak, guard.notice), (5, 59, false));
|
||||||
|
guard.observe(radius, 23.0, false); // idle, unavailable, hidden or no actual paint
|
||||||
|
assert_eq!((guard.cap, guard.streak), (5, 0));
|
||||||
|
for _ in 0..59 {
|
||||||
|
guard.observe(radius, 23.0, true);
|
||||||
|
}
|
||||||
|
guard.observe(radius, 22.0, true); // strictly over budget
|
||||||
|
assert_eq!((guard.cap, guard.streak), (5, 0));
|
||||||
|
for _ in 0..59 {
|
||||||
|
guard.observe(radius, 23.0, true);
|
||||||
|
}
|
||||||
|
assert_eq!(guard.cap, 5);
|
||||||
|
guard.observe(radius, 23.0, true);
|
||||||
|
assert_eq!((guard.cap, guard.notice), (4, true));
|
||||||
|
guard.notice = false; // Dismiss does not stop later reductions or reannounce.
|
||||||
|
for _ in 0..180 {
|
||||||
|
guard.observe(radius, 23.0, true);
|
||||||
|
}
|
||||||
|
assert_eq!((guard.cap, guard.streak, guard.notice), (2, 0, false));
|
||||||
|
assert_eq!(crate::blur::schedule_capped(radius, guard.cap).unwrap().0, 2);
|
||||||
|
assert_eq!(radius, 40.0, "quality guard never turns radius down or off");
|
||||||
|
guard.undo();
|
||||||
|
assert_eq!((guard.cap, guard.notice, guard.opted_out), (5, false, true));
|
||||||
|
guard.reset(); // theme/radius changes do not revoke the session opt-out
|
||||||
|
for _ in 0..120 {
|
||||||
|
guard.observe(18.0, 23.0, true);
|
||||||
|
}
|
||||||
|
assert_eq!(guard.cap, 5);
|
||||||
|
for radius in [0.0, 1.0, 8.0] {
|
||||||
|
let mut guard = crate::blur::Guard::default();
|
||||||
|
for _ in 0..120 {
|
||||||
|
guard.observe(radius, 23.0, true);
|
||||||
|
}
|
||||||
|
assert_eq!((guard.cap, guard.notice), (5, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
#[test]
|
||||||
|
fn blur_guard_requires_consecutive_visible_actual_paints() {
|
||||||
|
let ctx = egui::Context::default();
|
||||||
|
let mut session = crate::blur::Session::default();
|
||||||
|
let mut time = 0.0;
|
||||||
|
let mut feed =
|
||||||
|
|session: &mut crate::blur::Session, rendered: bool, continuous: bool, visible: bool, available: bool, revision: u64, radius: f32| {
|
||||||
|
time += 0.023;
|
||||||
|
let mut raw = egui::RawInput { time: Some(time), ..Default::default() };
|
||||||
|
raw.viewports.entry(egui::ViewportId::ROOT).or_default().occluded = Some(!visible);
|
||||||
|
let _ = ctx.run_ui(raw, |ui| {
|
||||||
|
ui.ctx().request_repaint(); // Deterministic harness only; product never does this for sampling.
|
||||||
|
let paint = rendered.then(|| crate::blur::PaintedFrame {
|
||||||
|
frame: ui.ctx().cumulative_frame_nr().saturating_sub(1),
|
||||||
|
interval_ms: Some(23.0),
|
||||||
|
continuous,
|
||||||
|
});
|
||||||
|
session.sample(ui.ctx(), revision, radius, available, paint);
|
||||||
|
session.sample(ui.ctx(), revision, radius, available, paint); // egui re-pass duplicate
|
||||||
|
});
|
||||||
|
};
|
||||||
|
feed(&mut session, false, true, true, true, 1, 40.0);
|
||||||
|
feed(&mut session, false, true, true, true, 1, 40.0);
|
||||||
|
for _ in 0..59 {
|
||||||
|
feed(&mut session, true, true, true, true, 1, 40.0);
|
||||||
|
}
|
||||||
|
assert_eq!((session.guard.cap, session.guard.streak), (5, 59));
|
||||||
|
for (rendered, continuous, visible, available) in
|
||||||
|
[(false, true, true, true), (true, false, true, true), (true, true, false, true), (true, true, true, false)]
|
||||||
|
{
|
||||||
|
feed(&mut session, rendered, continuous, visible, available, 1, 40.0);
|
||||||
|
assert_eq!((session.guard.cap, session.guard.streak), (5, 0));
|
||||||
|
feed(&mut session, true, true, true, true, 1, 40.0);
|
||||||
|
assert_eq!(session.guard.streak, 1);
|
||||||
|
}
|
||||||
|
feed(&mut session, true, true, true, true, 2, 40.0); // theme contents changed
|
||||||
|
assert_eq!(session.guard.streak, 0);
|
||||||
|
feed(&mut session, true, true, true, true, 2, 18.0); // radius changed
|
||||||
|
assert_eq!(session.guard.streak, 0);
|
||||||
|
for _ in 0..59 {
|
||||||
|
feed(&mut session, true, true, true, true, 2, 18.0);
|
||||||
|
}
|
||||||
|
assert_eq!((session.guard.cap, session.guard.streak), (5, 59));
|
||||||
|
feed(&mut session, true, true, true, true, 2, 18.0);
|
||||||
|
assert_eq!((session.guard.cap, session.guard.notice), (2, true));
|
||||||
|
session.guard.undo();
|
||||||
|
feed(&mut session, true, true, true, true, 3, 40.0);
|
||||||
|
for _ in 0..100 {
|
||||||
|
feed(&mut session, true, true, true, true, 3, 40.0);
|
||||||
|
}
|
||||||
|
assert_eq!((session.guard.cap, session.guard.opted_out), (5, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
@@ -707,6 +811,11 @@ mod tests {
|
|||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
impl BlurFixture {
|
impl BlurFixture {
|
||||||
|
fn radius(&mut self, radius: f32) {
|
||||||
|
// Explicit PR5 rendering harness activation, independent of theme declarations.
|
||||||
|
self.app.blur_session.harness_radius = Some(radius);
|
||||||
|
self.app.blur.set_radius(radius).unwrap();
|
||||||
|
}
|
||||||
fn new(srgb: bool, policy: crate::blur::FixturePolicy) -> Option<Self> {
|
fn new(srgb: bool, policy: crate::blur::FixturePolicy) -> Option<Self> {
|
||||||
use super::*;
|
use super::*;
|
||||||
let Some((adapter, device, queue)) = offscreen_device() else {
|
let Some((adapter, device, queue)) = offscreen_device() else {
|
||||||
@@ -731,6 +840,11 @@ mod tests {
|
|||||||
app.viewport.draw_stats = Some(view.stats());
|
app.viewport.draw_stats = Some(view.stats());
|
||||||
renderer.write().callback_resources.insert(view);
|
renderer.write().callback_resources.insert(view);
|
||||||
app.blur = crate::blur::fixture_state(&adapter, &device, &queue, &renderer, format, policy);
|
app.blur = crate::blur::fixture_state(&adapter, &device, &queue, &renderer, format, policy);
|
||||||
|
assert_eq!(
|
||||||
|
app.blur.software_adapter(),
|
||||||
|
adapter.get_info().device_type == wgpu::DeviceType::Cpu,
|
||||||
|
"fixture must retain real adapter identity"
|
||||||
|
);
|
||||||
if policy == crate::blur::FixturePolicy::Rendering {
|
if policy == crate::blur::FixturePolicy::Rendering {
|
||||||
assert_eq!(app.blur.capability(), crate::blur::Capability::Available, "blur fixture requires real rendering capabilities");
|
assert_eq!(app.blur.capability(), crate::blur::Capability::Available, "blur fixture requires real rendering capabilities");
|
||||||
}
|
}
|
||||||
@@ -892,11 +1006,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let unavailable = Capability::Unavailable("software adapter (gpu-software)");
|
let unavailable = Capability::Unavailable("software adapter (gpu-software)");
|
||||||
assert_eq!(f.app.blur.capability(), unavailable);
|
assert_eq!(f.app.blur.capability(), unavailable);
|
||||||
f.app.blur.set_radius(18.0).unwrap();
|
f.radius(18.0);
|
||||||
for _ in 0..6 {
|
for _ in 0..6 {
|
||||||
f.frame();
|
f.frame();
|
||||||
|
assert_eq!(f.app.blur.radius(), 18.0, "each prepared fallback frame retains the requested radius");
|
||||||
}
|
}
|
||||||
assert_eq!(f.app.blur.capability(), unavailable);
|
assert_eq!(f.app.blur.capability(), unavailable);
|
||||||
|
assert_eq!(f.app.blur.radius(), 18.0, "production fallback preserves the actual requested radius through prepare_blur");
|
||||||
|
assert_eq!(f.app.blur_session.harness_radius, Some(18.0));
|
||||||
|
assert_eq!(crate::prefs::blur_availability(&f.app), crate::theme_options::Availability::Unavailable("prefs.blur_software"));
|
||||||
let counts = f.app.blur.counters();
|
let counts = f.app.blur.counters();
|
||||||
assert_eq!((counts.textures_created, counts.buffers_created, counts.bind_groups_created, counts.pipelines_created), (0, 0, 0, 0));
|
assert_eq!((counts.textures_created, counts.buffers_created, counts.bind_groups_created, counts.pipelines_created), (0, 0, 0, 0));
|
||||||
assert_eq!((counts.markers_emitted, counts.markers_drawn), (0, 0));
|
assert_eq!((counts.markers_emitted, counts.markers_drawn), (0, 0));
|
||||||
@@ -905,6 +1023,152 @@ mod tests {
|
|||||||
assert!(f.renderer.read().frame_compositor.is_none());
|
assert!(f.renderer.read().frame_compositor.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
#[test]
|
||||||
|
fn blur_offered_zero_has_no_effect_work() {
|
||||||
|
let Some(mut f) = BlurFixture::new(false, crate::blur::FixturePolicy::Production) else { return };
|
||||||
|
assert!(f.app.blur_session.harness_radius.is_none(), "zero must come from the declared theme option");
|
||||||
|
crate::prefs::control(
|
||||||
|
&mut f.app,
|
||||||
|
&serde_json::json!({"theme_import":{
|
||||||
|
"format":"solidcraft-theme/1", "name":"Zero blur", "base":"dark",
|
||||||
|
"options":[{"id":"blur","type":"range","default":0}]
|
||||||
|
}}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
for _ in 0..6 {
|
||||||
|
f.frame();
|
||||||
|
}
|
||||||
|
assert!(crate::theme::blur_intent(&f.ctx).offered);
|
||||||
|
assert_eq!(crate::theme::blur_intent(&f.ctx).radius, 0.0);
|
||||||
|
assert_eq!(f.app.blur.radius(), 0.0);
|
||||||
|
assert!(f.app.blur_session.harness_radius.is_none());
|
||||||
|
let counters = serde_json::to_value(f.app.blur.counters()).unwrap();
|
||||||
|
for (name, count) in counters.as_object().unwrap() {
|
||||||
|
assert_eq!(count, 0, "{name}");
|
||||||
|
}
|
||||||
|
assert!(f.renderer.read().frame_compositor.is_none());
|
||||||
|
assert!(f.app.blur.painted_frame().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
#[test]
|
||||||
|
fn blur_search_picker_popup_and_tooltips_keep_native_backing() {
|
||||||
|
use crate::ui_kit::{SearchChoice, SearchPicker};
|
||||||
|
let Some(mut f) = BlurFixture::new(false, crate::blur::FixturePolicy::Rendering) else { return };
|
||||||
|
f.radius(18.0);
|
||||||
|
let heading = "Long provider heading ".repeat(4);
|
||||||
|
let note = "Long discovery note ".repeat(20);
|
||||||
|
for target in ["choice", "group", "heading", "note"] {
|
||||||
|
let ctx = crate::fonts::test_context();
|
||||||
|
ctx.global_style_mut(|s| {
|
||||||
|
s.visuals.window_fill = egui::Color32::from_rgba_unmultiplied(40, 60, 80, 180);
|
||||||
|
s.interaction.tooltip_delay = 0.0;
|
||||||
|
s.interaction.show_tooltips_only_when_still = false;
|
||||||
|
s.animation_time = 0.0;
|
||||||
|
});
|
||||||
|
let choices = [
|
||||||
|
SearchChoice { value: "one", label: "One", tip: "First choice detail", group: Some(&heading) },
|
||||||
|
SearchChoice { value: "two", label: "Two", tip: "Second choice detail", group: (target != "heading").then_some("Other") },
|
||||||
|
];
|
||||||
|
let mut widget = egui::Id::NULL;
|
||||||
|
let mut time = 0.0;
|
||||||
|
let mut frame = |events| {
|
||||||
|
// Native tooltips suppress movement just after a click for 100 ms.
|
||||||
|
// Advance deterministic input time beyond that grace period; never
|
||||||
|
// depend on the machine's speed to make a hover eligible.
|
||||||
|
time += 0.25;
|
||||||
|
crate::scenario::clear_handles();
|
||||||
|
crate::test_support::frame(
|
||||||
|
&ctx,
|
||||||
|
egui::RawInput {
|
||||||
|
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))),
|
||||||
|
events,
|
||||||
|
time: Some(time),
|
||||||
|
focused: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
|ui| {
|
||||||
|
let _blur = f.app.blur.begin(ui.ctx(), true);
|
||||||
|
ui.set_width(300.0);
|
||||||
|
crate::ui_kit::scope(ui, |ui| {
|
||||||
|
let result = crate::ui_kit::search_picker(
|
||||||
|
ui,
|
||||||
|
SearchPicker {
|
||||||
|
id: "blur_picker",
|
||||||
|
handles: "blur:picker",
|
||||||
|
current: "One",
|
||||||
|
current_value: "one",
|
||||||
|
hint: "Search",
|
||||||
|
choices: &choices,
|
||||||
|
custom: false,
|
||||||
|
note: Some(¬e),
|
||||||
|
open_group: None,
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
widget = result.response.id;
|
||||||
|
assert!(result.inner.is_none(), "hovering must not pick a value");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
frame(vec![]);
|
||||||
|
let at = crate::scenario::handle_at("blur:picker").unwrap();
|
||||||
|
frame(crate::test_support::pointer(at, true));
|
||||||
|
frame(crate::test_support::pointer(at, false));
|
||||||
|
let mut output = frame(vec![]);
|
||||||
|
let at = match target {
|
||||||
|
"choice" => crate::scenario::handle_at("blur:picker:one").unwrap(),
|
||||||
|
"group" => crate::scenario::handle_at(&format!("blur:picker:group:{heading}")).unwrap(),
|
||||||
|
_ => output
|
||||||
|
.shapes
|
||||||
|
.iter()
|
||||||
|
.find_map(|s| match &s.shape {
|
||||||
|
egui::Shape::Text(t) if t.galley.job.text.starts_with(if target == "note" { "Long discovery" } else { "Long provider" }) => {
|
||||||
|
Some(t.pos + t.galley.rect.center().to_vec2())
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.expect("picker heading or note has visible text"),
|
||||||
|
};
|
||||||
|
for _ in 0..4 {
|
||||||
|
output = frame(vec![egui::Event::PointerMoved(at)]);
|
||||||
|
}
|
||||||
|
let mut leaves = Vec::new();
|
||||||
|
for clipped in &output.shapes {
|
||||||
|
crate::test_support::each_shape(&clipped.shape, &mut |shape| leaves.push(shape.clone()));
|
||||||
|
}
|
||||||
|
let markers: Vec<_> = leaves
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter_map(|(i, shape)| match shape {
|
||||||
|
egui::Shape::Callback(cb) => Some((i, cb.rect)),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(markers.len(), 2, "{target}: popup and tooltip both have backing markers");
|
||||||
|
let popup = ctx.read_response(widget.with("popup")).unwrap().rect;
|
||||||
|
assert_eq!(markers[0].1, popup, "the marker covers popup padding and scrollbars");
|
||||||
|
assert!(
|
||||||
|
leaves
|
||||||
|
.iter()
|
||||||
|
.skip(markers[0].0 + 1)
|
||||||
|
.find(|shape| !matches!(shape, egui::Shape::Noop))
|
||||||
|
.is_some_and(|shape| matches!(shape, egui::Shape::Rect(_))),
|
||||||
|
"backing precedes native popup chrome"
|
||||||
|
);
|
||||||
|
assert!(ctx.memory(|m| m.has_focus(egui::Id::new("blur_picker").with("search"))), "native search focus survives hover");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn assert_backdrop_passes(before: crate::blur::Counters, after: crate::blur::Counters, steps: usize) {
|
||||||
|
let markers = after.markers_drawn - before.markers_drawn;
|
||||||
|
assert!(markers > 0, "the fixture must paint a visible backing boundary");
|
||||||
|
assert_eq!(after.blur_passes - before.blur_passes, 2 * steps as u64 * markers, "each backing boundary filters the preceding UI");
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
#[test]
|
#[test]
|
||||||
fn blur_full_egui_composition() {
|
fn blur_full_egui_composition() {
|
||||||
@@ -927,7 +1191,7 @@ mod tests {
|
|||||||
f.app.blur.set_surface_alpha(Some(180));
|
f.app.blur.set_surface_alpha(Some(180));
|
||||||
f.app.cam = original_camera;
|
f.app.cam = original_camera;
|
||||||
f.app.preferences.viewport_background = original_background;
|
f.app.preferences.viewport_background = original_background;
|
||||||
f.app.blur.set_radius(0.0).unwrap();
|
f.radius(0.0);
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
f.frame();
|
f.frame();
|
||||||
}
|
}
|
||||||
@@ -939,19 +1203,27 @@ mod tests {
|
|||||||
for radius in [8.0, 18.0, 40.0] {
|
for radius in [8.0, 18.0, 40.0] {
|
||||||
f.app.cam = original_camera;
|
f.app.cam = original_camera;
|
||||||
f.app.preferences.viewport_background = original_background;
|
f.app.preferences.viewport_background = original_background;
|
||||||
f.app.blur.set_radius(radius).unwrap();
|
f.radius(radius);
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
f.frame();
|
f.frame();
|
||||||
}
|
}
|
||||||
let before = f.app.blur.counters();
|
let before = f.app.blur.counters();
|
||||||
|
assert_eq!(
|
||||||
|
crate::prefs::blur_availability(&f.app),
|
||||||
|
crate::theme_options::Availability::Available,
|
||||||
|
"positive rendering fixtures must pass the shared app-aware capability policy"
|
||||||
|
);
|
||||||
|
assert_eq!(f.app.blur.radius(), radius);
|
||||||
|
assert!(f.app.blur.painted_frame().is_some(), "guard evidence comes from actual submitted visible marker work");
|
||||||
let source = f.pixels(true);
|
let source = f.pixels(true);
|
||||||
|
let resting = f.pixels(false);
|
||||||
f.frame();
|
f.frame();
|
||||||
let after = f.app.blur.counters();
|
let after = f.app.blur.counters();
|
||||||
assert_eq!(before.source_passes, after.source_passes, "resting source");
|
assert_eq!(before.source_passes, after.source_passes, "resting source");
|
||||||
assert_eq!(before.blur_passes, after.blur_passes, "resting chain");
|
assert_backdrop_passes(before, after, crate::blur::schedule(radius).unwrap().0);
|
||||||
assert!(after.cache_hits > before.cache_hits);
|
assert!(after.cache_hits > before.cache_hits);
|
||||||
assert!(after.markers_drawn > before.markers_drawn);
|
|
||||||
assert_eq!(f.pixels(true), source);
|
assert_eq!(f.pixels(true), source);
|
||||||
|
assert_eq!(f.pixels(false), resting, "resting ordered composition is stable");
|
||||||
// Change panels only: the immutable source must be byte-identical.
|
// Change panels only: the immutable source must be byte-identical.
|
||||||
f.app.blur.set_surface_alpha(Some(120));
|
f.app.blur.set_surface_alpha(Some(120));
|
||||||
f.frame();
|
f.frame();
|
||||||
@@ -975,7 +1247,7 @@ mod tests {
|
|||||||
f.frame();
|
f.frame();
|
||||||
let after = f.app.blur.counters();
|
let after = f.app.blur.counters();
|
||||||
assert_eq!(after.source_passes, before.source_passes + 1);
|
assert_eq!(after.source_passes, before.source_passes + 1);
|
||||||
assert_eq!(after.blur_passes - before.blur_passes, 2 * crate::blur::schedule(radius).unwrap().0 as u64);
|
assert_backdrop_passes(before, after, crate::blur::schedule(radius).unwrap().0);
|
||||||
f.app.cam = original_camera;
|
f.app.cam = original_camera;
|
||||||
f.frame();
|
f.frame();
|
||||||
f.app.preferences.viewport_background = Some([3, 37, 93]);
|
f.app.preferences.viewport_background = Some([3, 37, 93]);
|
||||||
@@ -987,7 +1259,21 @@ mod tests {
|
|||||||
f.app.preferences.viewport_background = original_background;
|
f.app.preferences.viewport_background = original_background;
|
||||||
f.frame();
|
f.frame();
|
||||||
}
|
}
|
||||||
f.app.blur.set_radius(0.0).unwrap();
|
let before = f.app.blur.counters();
|
||||||
|
f.app.blur_session.guard.cap = 3; // Explicit deterministic quality input in this GPU fixture.
|
||||||
|
f.frame();
|
||||||
|
let reduced = f.app.blur.counters();
|
||||||
|
assert_eq!(f.app.blur.radius(), 40.0);
|
||||||
|
assert_eq!(reduced.source_passes, before.source_passes + 1, "tier change invalidates the source cache");
|
||||||
|
assert_backdrop_passes(before, reduced, 3);
|
||||||
|
f.frame();
|
||||||
|
let cached = f.app.blur.counters();
|
||||||
|
assert_eq!(cached.source_passes, reduced.source_passes, "reduced tier has its own stable source cache");
|
||||||
|
assert_backdrop_passes(reduced, cached, 3);
|
||||||
|
f.app.blur_session.guard.cap = 5;
|
||||||
|
f.frame();
|
||||||
|
assert_backdrop_passes(cached, f.app.blur.counters(), 5);
|
||||||
|
f.radius(0.0);
|
||||||
for _ in 0..2 {
|
for _ in 0..2 {
|
||||||
f.frame();
|
f.frame();
|
||||||
}
|
}
|
||||||
@@ -1026,7 +1312,7 @@ mod tests {
|
|||||||
f.app.blur.set_surface_alpha(Some(180));
|
f.app.blur.set_surface_alpha(Some(180));
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
for radius in [0.0, 18.0, 0.0] {
|
for radius in [0.0, 18.0, 0.0] {
|
||||||
f.app.blur.set_radius(radius).unwrap();
|
f.radius(radius);
|
||||||
for _ in 0..6 {
|
for _ in 0..6 {
|
||||||
f.frame();
|
f.frame();
|
||||||
}
|
}
|
||||||
@@ -1035,7 +1321,17 @@ mod tests {
|
|||||||
times.sort_by(f64::total_cmp);
|
times.sort_by(f64::total_cmp);
|
||||||
let after = f.app.blur.counters();
|
let after = f.app.blur.counters();
|
||||||
assert_eq!(after.source_passes, before.source_passes);
|
assert_eq!(after.source_passes, before.source_passes);
|
||||||
assert_eq!(after.blur_passes, before.blur_passes);
|
if radius == 0.0 {
|
||||||
|
assert_eq!(after.blur_passes, before.blur_passes);
|
||||||
|
assert_eq!(after.markers_drawn, before.markers_drawn);
|
||||||
|
} else {
|
||||||
|
assert_backdrop_passes(before, after, crate::blur::schedule(radius).unwrap().0);
|
||||||
|
assert_eq!(after.cache_hits - before.cache_hits, 30, "all resting sources are cached");
|
||||||
|
}
|
||||||
|
assert_eq!(after.textures_created, before.textures_created);
|
||||||
|
assert_eq!(after.buffers_created, before.buffers_created);
|
||||||
|
assert_eq!(after.bind_groups_created, before.bind_groups_created);
|
||||||
|
assert_eq!(after.pipelines_created, before.pipelines_created);
|
||||||
rows.push(serde_json::json!({"radius_px":radius, "samples":times.len(), "min_ms":times[0],
|
rows.push(serde_json::json!({"radius_px":radius, "samples":times.len(), "min_ms":times[0],
|
||||||
"median_ms":(times[14] + times[15]) / 2.0, "max_ms":times[29], "before":before, "after":after}));
|
"median_ms":(times[14] + times[15]) / 2.0, "max_ms":times[29], "before":before, "after":after}));
|
||||||
}
|
}
|
||||||
@@ -1051,7 +1347,7 @@ mod tests {
|
|||||||
fn blur_suspend_releases_targets() {
|
fn blur_suspend_releases_targets() {
|
||||||
let Some(mut f) = BlurFixture::new(false, crate::blur::FixturePolicy::Rendering) else { return };
|
let Some(mut f) = BlurFixture::new(false, crate::blur::FixturePolicy::Rendering) else { return };
|
||||||
f.app.blur.set_surface_alpha(Some(180));
|
f.app.blur.set_surface_alpha(Some(180));
|
||||||
f.app.blur.set_radius(18.0).unwrap();
|
f.radius(18.0);
|
||||||
for _ in 0..3 {
|
for _ in 0..3 {
|
||||||
f.frame();
|
f.frame();
|
||||||
}
|
}
|
||||||
@@ -1060,7 +1356,7 @@ mod tests {
|
|||||||
assert_eq!(f.app.blur.counters().live_bytes, 0);
|
assert_eq!(f.app.blur.counters().live_bytes, 0);
|
||||||
f.frame();
|
f.frame();
|
||||||
assert!(f.app.blur.counters().live_textures > 0);
|
assert!(f.app.blur.counters().live_textures > 0);
|
||||||
f.app.blur.set_radius(0.0).unwrap();
|
f.radius(0.0);
|
||||||
assert_eq!(f.app.blur.counters().live_textures, 0);
|
assert_eq!(f.app.blur.counters().live_textures, 0);
|
||||||
assert_eq!(f.app.blur.counters().live_bind_groups, 0);
|
assert_eq!(f.app.blur.counters().live_bind_groups, 0);
|
||||||
assert_eq!(f.app.blur.counters().live_pipelines, 0);
|
assert_eq!(f.app.blur.counters().live_pipelines, 0);
|
||||||
@@ -1071,7 +1367,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn blur_late_layer_transform_moves_source_and_invalidates_cache() {
|
fn blur_late_layer_transform_moves_source_and_invalidates_cache() {
|
||||||
let Some(mut f) = BlurFixture::new(false, crate::blur::FixturePolicy::Rendering) else { return };
|
let Some(mut f) = BlurFixture::new(false, crate::blur::FixturePolicy::Rendering) else { return };
|
||||||
f.app.blur.set_radius(18.0).unwrap();
|
f.radius(18.0);
|
||||||
f.app.blur.set_surface_alpha(Some(180));
|
f.app.blur.set_surface_alpha(Some(180));
|
||||||
for _ in 0..6 {
|
for _ in 0..6 {
|
||||||
f.frame();
|
f.frame();
|
||||||
@@ -1091,12 +1387,12 @@ mod tests {
|
|||||||
assert_eq!(f.app.blur.source_geometry().unwrap(), local.map(|r| transform.mul_rect(r)), "viewport, clip and background all transform once");
|
assert_eq!(f.app.blur.source_geometry().unwrap(), local.map(|r| transform.mul_rect(r)), "viewport, clip and background all transform once");
|
||||||
let after = f.app.blur.counters();
|
let after = f.app.blur.counters();
|
||||||
assert_eq!(after.source_passes, before.source_passes + 1);
|
assert_eq!(after.source_passes, before.source_passes + 1);
|
||||||
assert_eq!(after.blur_passes, before.blur_passes + 6);
|
assert_backdrop_passes(before, after, 3);
|
||||||
assert_eq!(after.textures_created, before.textures_created, "placement changes reuse storage");
|
assert_eq!(after.textures_created, before.textures_created, "placement changes reuse storage");
|
||||||
assert_eq!(&f.pixels(true)[offset..offset + 4], &outside, "old location is outside the transformed clip");
|
assert_eq!(&f.pixels(true)[offset..offset + 4], &outside, "old location is outside the transformed clip");
|
||||||
f.frame();
|
f.frame();
|
||||||
assert_eq!(f.app.blur.counters().source_passes, after.source_passes, "unchanged transformed placement is cached");
|
assert_eq!(f.app.blur.counters().source_passes, after.source_passes, "unchanged transformed placement is cached");
|
||||||
assert_eq!(f.app.blur.counters().blur_passes, after.blur_passes);
|
assert_backdrop_passes(after, f.app.blur.counters(), 3);
|
||||||
// A forced miss must still paint at the transformed location, not merely hash it.
|
// A forced miss must still paint at the transformed location, not merely hash it.
|
||||||
f.app.cam.yaw += 0.1;
|
f.app.cam.yaw += 0.1;
|
||||||
f.frame();
|
f.frame();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
//! centre that drags it across the model (snapping as a pick would) and takes it out when
|
//! centre that drags it across the model (snapping as a pick would) and takes it out when
|
||||||
//! clicked, and a pick elsewhere on a face that already has a hole moves the hole there.
|
//! clicked, and a pick elsewhere on a face that already has a hole moves the hole there.
|
||||||
|
|
||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
use solidcraft_engine::geom::prelude::*;
|
use solidcraft_engine::geom::prelude::*;
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -305,7 +306,7 @@ pub(crate) fn show(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painte
|
|||||||
crate::icons::markers::hole_handle(painter, c, if hot { t.accent } else { t.manipulator }, hot);
|
crate::icons::markers::hole_handle(painter, c, if hot { t.accent } else { t.manipulator }, hot);
|
||||||
crate::scenario::publish_handle(&format!("hole:{n}"), c);
|
crate::scenario::publish_handle(&format!("hole:{n}"), c);
|
||||||
if !r.dragged() {
|
if !r.dragged() {
|
||||||
r.on_hover_text(crate::i18n::tr_label("Drag to move the hole; click to remove it"));
|
r.backing_on_hover_text(crate::i18n::tr_label("Drag to move the hole; click to remove it"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some((i, to)) = moved
|
if let Some((i, to)) = moved
|
||||||
|
|||||||
@@ -645,7 +645,8 @@ pub(crate) fn show(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painte
|
|||||||
let at = (p + vec2(20.0, 22.0)).clamp(proj.rect.min, proj.rect.max - vec2(260.0, 82.0).min(proj.rect.size()));
|
let at = (p + vec2(20.0, 22.0)).clamp(proj.rect.min, proj.rect.max - vec2(260.0, 82.0).min(proj.rect.size()));
|
||||||
let host = Container::new(ui.ctx(), Surface::Canvas);
|
let host = Container::new(ui.ctx(), Surface::Canvas);
|
||||||
host.area(egui::Id::new("joint_move_readout")).interactable(false).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
host.area(egui::Id::new("joint_move_readout")).interactable(false).fixed_pos(at).order(egui::Order::Foreground).show(ui.ctx(), |ui| {
|
||||||
host.frame(ui.ctx(), tk.overlay, crate::frame::Side::Right).inner_margin(tk.space_8).show(ui, |ui| {
|
let frame = host.frame(ui.ctx(), tk.overlay, crate::frame::Side::Right).inner_margin(tk.space_8);
|
||||||
|
Container::show_backing(ui, frame, |ui| {
|
||||||
let value = if d.axis.angular {
|
let value = if d.axis.angular {
|
||||||
format!(
|
format!(
|
||||||
"{}{:.precision$}°",
|
"{}{:.precision$}°",
|
||||||
@@ -678,7 +679,8 @@ pub(crate) fn show(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painte
|
|||||||
let host = Container::new(ui.ctx(), Surface::Canvas);
|
let host = Container::new(ui.ctx(), Surface::Canvas);
|
||||||
let mut done = false;
|
let mut done = false;
|
||||||
host.area(egui::Id::new("joint_move_help")).fixed_pos(proj.rect.left_bottom() + vec2(16.0, -148.0)).show(ui.ctx(), |ui| {
|
host.area(egui::Id::new("joint_move_help")).fixed_pos(proj.rect.left_bottom() + vec2(16.0, -148.0)).show(ui.ctx(), |ui| {
|
||||||
host.frame(ui.ctx(), tk.overlay, crate::frame::Side::Right).inner_margin(tk.space_8).show(ui, |ui| {
|
let frame = host.frame(ui.ctx(), tk.overlay, crate::frame::Side::Right).inner_margin(tk.space_8);
|
||||||
|
Container::show_backing(ui, frame, |ui| {
|
||||||
ui.label(app.tr("joint_move.help"));
|
ui.label(app.tr("joint_move.help"));
|
||||||
if mode.axes.iter().any(|a| a.angular) {
|
if mode.axes.iter().any(|a| a.angular) {
|
||||||
ui.label(app.tr("joint_move.precision"));
|
ui.label(app.tr("joint_move.precision"));
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
//! already uses is reported and only taken over on request; keys the app itself handles (Esc, S,
|
//! already uses is reported and only taken over on request; keys the app itself handles (Esc, S,
|
||||||
//! V, Delete, Ctrl+Z…) can't be bound. Changes are kept between runs.
|
//! V, Delete, Ctrl+Z…) can't be bound. Changes are kept between runs.
|
||||||
|
|
||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -655,7 +656,7 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
let export = ui_kit::icon_label_button(ui, app.tr("prefs.profile_export"), "export_tray", true, false)
|
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"));
|
.backing_on_hover_text(app.tr_label("Keys, S box favorites and the toolbar, as a file"));
|
||||||
crate::scenario::publish_target("keymap:export", export.rect);
|
crate::scenario::publish_target("keymap:export", export.rect);
|
||||||
if export.clicked() {
|
if export.clicked() {
|
||||||
action = Some((String::new(), "export"));
|
action = Some((String::new(), "export"));
|
||||||
@@ -672,7 +673,7 @@ pub fn page(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
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::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)
|
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"));
|
.backing_on_hover_text(app.tr("ui.sketch_keys_only_tip"));
|
||||||
if let Some(chosen) = outcome(app, ui) {
|
if let Some(chosen) = outcome(app, ui) {
|
||||||
action = Some((String::new(), chosen));
|
action = Some((String::new(), chosen));
|
||||||
}
|
}
|
||||||
@@ -910,7 +911,7 @@ fn shortcut_row(
|
|||||||
let name =
|
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));
|
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);
|
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);
|
let response = ui.interact(name, ui.id().with("command"), egui::Sense::hover()).backing_on_hover_text(command.id);
|
||||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, ui.is_enabled(), format!("{label} ({})", 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 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 total = widths.iter().sum::<f32>() + widths.len().saturating_sub(1) as f32 * t.space_8;
|
||||||
@@ -937,7 +938,7 @@ fn shortcut_row(
|
|||||||
ui_kit::quiet_icon_button_at(ui, r, "plus", app.tr_label("Click, then press the new key"))
|
ui_kit::quiet_icon_button_at(ui, r, "plus", app.tr_label("Click, then press the new key"))
|
||||||
};
|
};
|
||||||
if editing {
|
if editing {
|
||||||
let capture = capture.on_hover_text(app.tr_label("Click, then press the new key"));
|
let capture = capture.backing_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) };
|
let name = if *slot == 0 { command.id.to_owned() } else { format!("{}:{slot}", command.id) };
|
||||||
crate::scenario::publish_target(&format!("keymap:capture:{name}"), capture.rect);
|
crate::scenario::publish_target(&format!("keymap:capture:{name}"), capture.rect);
|
||||||
if ui.is_rect_visible(capture.rect) {
|
if ui.is_rect_visible(capture.rect) {
|
||||||
|
|||||||
@@ -455,6 +455,8 @@ pub struct Services {
|
|||||||
/// frame, so it only reads a value kept up to date elsewhere. None, or `None` from it: the
|
/// frame, so it only reads a value kept up to date elsewhere. None, or `None` from it: the
|
||||||
/// window toolkit's theme.
|
/// window toolkit's theme.
|
||||||
pub desktop_dark: Option<Box<dyn Fn() -> Option<bool>>>,
|
pub desktop_dark: Option<Box<dyn Fn() -> Option<bool>>>,
|
||||||
|
/// Lazy background observation; `needed` arms polling, the return is cached only.
|
||||||
|
pub reduce_transparency: Option<Box<dyn Fn(bool) -> Option<bool>>>,
|
||||||
/// Adapter names and descriptive labels from the desktop renderer's chosen backends.
|
/// Adapter names and descriptive labels from the desktop renderer's chosen backends.
|
||||||
pub graphics_adapters: Vec<(String, String)>,
|
pub graphics_adapters: Vec<(String, String)>,
|
||||||
/// HTTPS GET for the update check (`update`); None: never check (browser, tests).
|
/// HTTPS GET for the update check (`update`); None: never check (browser, tests).
|
||||||
@@ -513,6 +515,7 @@ pub struct SolveApp {
|
|||||||
/// Common panel foundation, internal opt-in; stays on the app across document tabs.
|
/// Common panel foundation, internal opt-in; stays on the app across document tabs.
|
||||||
pub(crate) panel_runtime: Option<panel_runtime::AppPanelRuntime>,
|
pub(crate) panel_runtime: Option<panel_runtime::AppPanelRuntime>,
|
||||||
pub blur: blur::State,
|
pub blur: blur::State,
|
||||||
|
pub(crate) blur_session: blur::Session,
|
||||||
pub(crate) viewport_status: ui_kit::StatusPresentation,
|
pub(crate) viewport_status: ui_kit::StatusPresentation,
|
||||||
pub(crate) viewport_status_fixture: viewport_status_fixture::Fixture,
|
pub(crate) viewport_status_fixture: viewport_status_fixture::Fixture,
|
||||||
/// Compact browser embed: retain model panels and joint controls without application chrome.
|
/// Compact browser embed: retain model panels and joint controls without application chrome.
|
||||||
@@ -790,6 +793,7 @@ impl SolveApp {
|
|||||||
panel_runtime: None,
|
panel_runtime: None,
|
||||||
viewport_status: Default::default(),
|
viewport_status: Default::default(),
|
||||||
blur: Default::default(),
|
blur: Default::default(),
|
||||||
|
blur_session: Default::default(),
|
||||||
viewport_status_fixture: Default::default(),
|
viewport_status_fixture: Default::default(),
|
||||||
session,
|
session,
|
||||||
ui: UiState::default(),
|
ui: UiState::default(),
|
||||||
@@ -2960,6 +2964,7 @@ impl SolveApp {
|
|||||||
|
|
||||||
/// Lay out the whole window.
|
/// Lay out the whole window.
|
||||||
pub fn ui(&mut self, ui: &mut egui::Ui) {
|
pub fn ui(&mut self, ui: &mut egui::Ui) {
|
||||||
|
prefs::prepare_blur(self, ui.ctx());
|
||||||
let blur_source = !self.home.open && !render_workspace::result_active(self);
|
let blur_source = !self.home.open && !render_workspace::result_active(self);
|
||||||
let _blur_scope = self.blur.begin(ui.ctx(), blur_source);
|
let _blur_scope = self.blur.begin(ui.ctx(), blur_source);
|
||||||
value_step::set_enabled(ui.ctx(), self.preferences.step_values);
|
value_step::set_enabled(ui.ctx(), self.preferences.step_values);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
use crate::navigation::NavCaps;
|
use crate::navigation::NavCaps;
|
||||||
use crate::navigation_bar::{self, Edge, Idle, Kind, Labels, MenuDisplay, NavigationBar, Position, Slot};
|
use crate::navigation_bar::{self, Edge, Idle, Kind, Labels, MenuDisplay, NavigationBar, Position, Slot};
|
||||||
use crate::theme::Tokens;
|
use crate::theme::Tokens;
|
||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
use crate::ui_kit::{self, Container, Surface};
|
use crate::ui_kit::{self, Container, Surface};
|
||||||
use crate::{SolveApp, viewport::NavMode};
|
use crate::{SolveApp, viewport::NavMode};
|
||||||
use egui::{Align2, Rect, pos2, vec2};
|
use egui::{Align2, Rect, pos2, vec2};
|
||||||
@@ -531,7 +532,7 @@ fn display_menu(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
item.selected = app.ui.perspective == perspective;
|
item.selected = app.ui.perspective == perspective;
|
||||||
item.enabled = !projection_locked(app);
|
item.enabled = !projection_locked(app);
|
||||||
let response = item.show(ui);
|
let response = item.show(ui);
|
||||||
let response = if projection_locked(app) { response.on_hover_text(app.tr("nav.perspective.sketch")) } else { response };
|
let response = if projection_locked(app) { response.backing_on_hover_text(app.tr("nav.perspective.sketch")) } else { response };
|
||||||
crate::scenario::publish_target(if perspective { "view:projection" } else { "view:orthographic" }, response.rect);
|
crate::scenario::publish_target(if perspective { "view:projection" } else { "view:orthographic" }, response.rect);
|
||||||
if response.clicked() && app.ui.perspective != perspective {
|
if response.clicked() && app.ui.perspective != perspective {
|
||||||
toggle_projection(app);
|
toggle_projection(app);
|
||||||
@@ -556,7 +557,7 @@ fn display_menu(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
// The View angles readout under the orientation gizmo (#6032).
|
// The View angles readout under the orientation gizmo (#6032).
|
||||||
let mut angles = NavigationMenuRow::new("view_angles", app.tr("ui.view_angles"), "select");
|
let mut angles = NavigationMenuRow::new("view_angles", app.tr("ui.view_angles"), "select");
|
||||||
angles.selected = app.preferences.show_view_angles;
|
angles.selected = app.preferences.show_view_angles;
|
||||||
let response = angles.show(ui).on_hover_text(app.tr("ui.view_angles_help"));
|
let response = angles.show(ui).backing_on_hover_text(app.tr("ui.view_angles_help"));
|
||||||
crate::scenario::publish_target("view:view_angles", response.rect);
|
crate::scenario::publish_target("view:view_angles", response.rect);
|
||||||
if response.clicked() {
|
if response.clicked() {
|
||||||
let _ = app.run("view.angle_readout", json!({"enabled": !app.preferences.show_view_angles}));
|
let _ = app.run("view.angle_readout", json!({"enabled": !app.preferences.show_view_angles}));
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ fn navigate(app: &mut SolveApp, action: Option<&str>, dir: Option<Vec3>) {
|
|||||||
|
|
||||||
fn control(app: &mut SolveApp, ui: &mut egui::Ui, bounds: Rect, action: &str, tooltip: &str) -> bool {
|
fn control(app: &mut SolveApp, ui: &mut egui::Ui, bounds: Rect, action: &str, tooltip: &str) -> bool {
|
||||||
crate::scenario::publish_handle(&format!("navigation_cube:control:{action}"), bounds.center());
|
crate::scenario::publish_handle(&format!("navigation_cube:control:{action}"), bounds.center());
|
||||||
let response = ui.interact(bounds, ui.id().with(("navigation_cube", action)), Sense::click()).on_hover_text(tooltip);
|
let response = ui.interact(bounds, ui.id().with(("navigation_cube", action)), Sense::click()).backing_on_hover_text(tooltip);
|
||||||
let hover = response.hovered();
|
let hover = response.hovered();
|
||||||
if response.clicked() {
|
if response.clicked() {
|
||||||
navigate(app, Some(action), None);
|
navigate(app, Some(action), None);
|
||||||
@@ -253,7 +253,11 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui, rect: Rect) {
|
|||||||
if let Some(face) = hovered.and_then(|i| faces.get(i)) {
|
if let Some(face) = hovered.and_then(|i| faces.get(i)) {
|
||||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||||
ui.interact(Rect::from_center_size(hover.unwrap_or(face.centre), vec2(2.0, 2.0)), ui.id().with("navigation_cube_tip"), Sense::hover())
|
ui.interact(Rect::from_center_size(hover.unwrap_or(face.centre), vec2(2.0, 2.0)), ui.id().with("navigation_cube_tip"), Sense::hover())
|
||||||
.on_hover_text(format!("{} view. Drag to orbit{}.", face.label, if app.preferences.free_orbit { "" } else { "; hold Shift to snap" }));
|
.backing_on_hover_text(format!(
|
||||||
|
"{} view. Drag to orbit{}.",
|
||||||
|
face.label,
|
||||||
|
if app.preferences.free_orbit { "" } else { "; hold Shift to snap" }
|
||||||
|
));
|
||||||
if ui.input(|i| i.pointer.primary_clicked()) {
|
if ui.input(|i| i.pointer.primary_clicked()) {
|
||||||
navigate(app, None, Some(face.direction));
|
navigate(app, None, Some(face.direction));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,7 +171,8 @@ pub(crate) fn show(app: &SolveApp, ctx: &egui::Context) {
|
|||||||
.fixed_pos(at)
|
.fixed_pos(at)
|
||||||
.interactable(false)
|
.interactable(false)
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
Container::popup_frame(ui.style()).fill(t.panel).show(ui, |ui| {
|
let frame = Container::popup_frame(ui.style()).fill(t.panel);
|
||||||
|
Container::show_backing(ui, frame, |ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.spinner();
|
ui.spinner();
|
||||||
ui.label(egui::RichText::new(format!("Loading parts {done}/{total}")).color(t.text));
|
ui.label(egui::RichText::new(format!("Loading parts {done}/{total}")).color(t.text));
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
use solidcraft_engine::panels::*;
|
use solidcraft_engine::panels::*;
|
||||||
|
|
||||||
/// Content receives this value separately from state/model/command services. All
|
/// Content receives this value separately from state/model/command services. All
|
||||||
@@ -267,7 +268,7 @@ impl PanelHost for EguiPanelHost<'_> {
|
|||||||
let ink = if presentation.attention { tokens.form_primary } else { tokens.form_ink };
|
let ink = if presentation.attention { tokens.form_primary } else { tokens.form_ink };
|
||||||
let mut response = ui.label(egui::RichText::new(&presentation.title).color(ink));
|
let mut response = ui.label(egui::RichText::new(&presentation.title).color(ink));
|
||||||
if let Some(status) = &presentation.status {
|
if let Some(status) = &presentation.status {
|
||||||
response = response.on_hover_text(status);
|
response = response.backing_on_hover_text(status);
|
||||||
}
|
}
|
||||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, &presentation.title));
|
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, &presentation.title));
|
||||||
}
|
}
|
||||||
@@ -285,7 +286,7 @@ impl PanelHost for EguiPanelHost<'_> {
|
|||||||
.inner;
|
.inner;
|
||||||
let clicked = response.clicked();
|
let clicked = response.clicked();
|
||||||
if let Some(reason) = &action.disabled_reason {
|
if let Some(reason) = &action.disabled_reason {
|
||||||
response.on_disabled_hover_text(reason);
|
response.backing_on_disabled_hover_text(reason);
|
||||||
}
|
}
|
||||||
if clicked
|
if clicked
|
||||||
&& failure.is_none()
|
&& failure.is_none()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
//! [`complete`] adds suggestions (parameters, functions, units) under any expression box: the
|
//! [`complete`] adds suggestions (parameters, functions, units) under any expression box: the
|
||||||
//! names come from `parameters.complete`, cached per document revision by [`publish`].
|
//! names come from `parameters.complete`, cached per document revision by [`publish`].
|
||||||
|
|
||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -273,7 +274,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
|||||||
});
|
});
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
if ui_kit::icon_label_button(ui, "Import…", "import_tray", true, false)
|
if ui_kit::icon_label_button(ui, "Import…", "import_tray", true, false)
|
||||||
.on_hover_text(crate::i18n::tr_label("CSV or JSON: updates existing parameters, adds new ones"))
|
.backing_on_hover_text(crate::i18n::tr_label("CSV or JSON: updates existing parameters, adds new ones"))
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
app.pick_open_command("parameters.import", json!({}), IMPORT_TYPES);
|
app.pick_open_command("parameters.import", json!({}), IMPORT_TYPES);
|
||||||
@@ -601,7 +602,7 @@ fn table(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog, rows: &[&R
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
row.cell(|ui| {
|
row.cell(|ui| {
|
||||||
ui_kit::text(ui, &shown, ui_kit::Text::Value(&format!("value:{}", r.name), error)).on_hover_text(&shown);
|
ui_kit::text(ui, &shown, ui_kit::Text::Value(&format!("value:{}", r.name), error)).backing_on_hover_text(&shown);
|
||||||
});
|
});
|
||||||
row.cell(|ui| {
|
row.cell(|ui| {
|
||||||
let mut comment = d.comments.get(&r.name).cloned().unwrap_or_else(|| r.comment.clone());
|
let mut comment = d.comments.get(&r.name).cloned().unwrap_or_else(|| r.comment.clone());
|
||||||
@@ -700,7 +701,7 @@ fn apply_held(app: &mut SolveApp, d: &mut ParamsDialog, rows: &[Row]) -> bool {
|
|||||||
/// back on applies what was waiting.
|
/// back on applies what was waiting.
|
||||||
fn apply_live_checkbox(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog, rows: &[Row]) {
|
fn apply_live_checkbox(app: &mut SolveApp, ui: &mut egui::Ui, d: &mut ParamsDialog, rows: &[Row]) {
|
||||||
let response = ui_kit::checkbox(ui, crate::i18n::tr("parameters.apply_live"), &mut app.preferences.parameters_apply_live)
|
let response = ui_kit::checkbox(ui, crate::i18n::tr("parameters.apply_live"), &mut app.preferences.parameters_apply_live)
|
||||||
.on_hover_text(crate::i18n::tr("parameters.apply_live_tip"));
|
.backing_on_hover_text(crate::i18n::tr("parameters.apply_live_tip"));
|
||||||
crate::scenario::publish_handle("params:apply_live", response.rect.center());
|
crate::scenario::publish_handle("params:apply_live", response.rect.center());
|
||||||
if response.changed() && app.preferences.parameters_apply_live {
|
if response.changed() && app.preferences.parameters_apply_live {
|
||||||
apply_held(app, d, rows);
|
apply_held(app, d, rows);
|
||||||
@@ -754,7 +755,7 @@ fn add_row(app: &mut SolveApp, ui: &mut egui::Ui, table: &mut ui_kit::Table<'_>,
|
|||||||
left |= !completed && response.lost_focus();
|
left |= !completed && response.lost_focus();
|
||||||
});
|
});
|
||||||
row.cell(|ui| {
|
row.cell(|ui| {
|
||||||
ui_kit::text(ui, &value, ui_kit::Text::Value("new_value", invalid)).on_hover_text(&value);
|
ui_kit::text(ui, &value, ui_kit::Text::Value("new_value", invalid)).backing_on_hover_text(&value);
|
||||||
});
|
});
|
||||||
row.cell(|ui| {
|
row.cell(|ui| {
|
||||||
let response = ui_kit::cell_editor(ui, comment_id, &mut d.new_comment, "Comment", false);
|
let response = ui_kit::cell_editor(ui, comment_id, &mut d.new_comment, "Comment", false);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ mod look;
|
|||||||
mod rest;
|
mod rest;
|
||||||
mod snap_fit;
|
mod snap_fit;
|
||||||
|
|
||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
use egui::{Pos2, vec2};
|
use egui::{Pos2, vec2};
|
||||||
use look::{
|
use look::{
|
||||||
CrossStyle, DIM, DIM_S, MARKER_ACTIVE, MARKER_HOT, RING_ACTIVE, RING_OPEN, RING_PX, RingStyle, angle_label, compass, crosshair, footprint, ring,
|
CrossStyle, DIM, DIM_S, MARKER_ACTIVE, MARKER_HOT, RING_ACTIVE, RING_OPEN, RING_PX, RingStyle, angle_label, compass, crosshair, footprint, ring,
|
||||||
@@ -250,7 +251,7 @@ pub(crate) fn show(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painte
|
|||||||
let (hovered, dragging) = (r.hovered(), r.dragged());
|
let (hovered, dragging) = (r.hovered(), r.dragged());
|
||||||
// The tip while pointing, not while dragging: the hand is busy and the footprint says it all.
|
// The tip while pointing, not while dragging: the hand is busy and the footprint says it all.
|
||||||
if hovered && !dragging {
|
if hovered && !dragging {
|
||||||
r.clone().on_hover_text(crate::i18n::tr_label(sp.move_tip));
|
r.clone().backing_on_hover_text(crate::i18n::tr_label(sp.move_tip));
|
||||||
}
|
}
|
||||||
// The handle not in use steps back while the other one works (the ring's drag is last frame's).
|
// The handle not in use steps back while the other one works (the ring's drag is last frame's).
|
||||||
let turning = ui.data(|m| m.get_temp::<bool>(id("turning"))).unwrap_or(false);
|
let turning = ui.data(|m| m.get_temp::<bool>(id("turning"))).unwrap_or(false);
|
||||||
@@ -340,7 +341,7 @@ pub(crate) fn show(app: &mut SolveApp, ui: &mut egui::Ui, painter: &egui::Painte
|
|||||||
compass(painter, &on, &dirs, style.active);
|
compass(painter, &on, &dirs, style.active);
|
||||||
}
|
}
|
||||||
if hovered && !dragging {
|
if hovered && !dragging {
|
||||||
r.clone().on_hover_text(crate::i18n::tr_label(turn.tip));
|
r.clone().backing_on_hover_text(crate::i18n::tr_label(turn.tip));
|
||||||
}
|
}
|
||||||
if hovered || dragging {
|
if hovered || dragging {
|
||||||
ui.ctx().set_cursor_icon(if dragging { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab });
|
ui.ctx().set_cursor_icon(if dragging { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab });
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use egui::RichText;
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::SolveApp;
|
use crate::SolveApp;
|
||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
|
|
||||||
/// How many turned-off ids a preferences file may name.
|
/// How many turned-off ids a preferences file may name.
|
||||||
pub const MAX_DISABLED: usize = 256;
|
pub const MAX_DISABLED: usize = 256;
|
||||||
@@ -212,7 +213,7 @@ fn page_in_dir(app: &mut SolveApp, ui: &mut egui::Ui, dir: Option<&Path>) {
|
|||||||
if !searching {
|
if !searching {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
let button = crate::ui_kit::icon_label_button(ui, crate::i18n::tr("prefs.plugins_show_folder"), "folder", true, false)
|
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)]));
|
.backing_on_hover_text(crate::i18n::format("ui.plugins_note", &[("path", &path)]));
|
||||||
crate::scenario::publish_target("prefs:plugins:folder", button.rect);
|
crate::scenario::publish_target("prefs:plugins:folder", button.rect);
|
||||||
if button.clicked() {
|
if button.clicked() {
|
||||||
if let Err(e) = std::fs::create_dir_all(dir) {
|
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||||
|
|||||||
+300
-25
@@ -1,6 +1,7 @@
|
|||||||
//! Grouped preferences composed with the shared UI kit. Changes apply immediately; Cancel
|
//! Grouped preferences composed with the shared UI kit. Changes apply immediately; Cancel
|
||||||
//! restores the opening snapshot, Apply keeps editing, and Defaults affects the current page.
|
//! restores the opening snapshot, Apply keeps editing, and Defaults affects the current page.
|
||||||
|
|
||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
use solidcraft_engine::geom::prelude::*;
|
use solidcraft_engine::geom::prelude::*;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
@@ -985,7 +986,7 @@ macro_rules! preference_settings {
|
|||||||
(@page $page:expr) => { Some($page) };
|
(@page $page:expr) => { Some($page) };
|
||||||
(@control $app:ident, $ui:ident, $field:ident, check($label:literal $(, $help:literal)?)) => {
|
(@control $app:ident, $ui:ident, $field:ident, check($label:literal $(, $help:literal)?)) => {
|
||||||
ui_kit::checkbox($ui, crate::i18n::tr_label($label), &mut $app.preferences.$field)
|
ui_kit::checkbox($ui, crate::i18n::tr_label($label), &mut $app.preferences.$field)
|
||||||
$(.on_hover_text(crate::i18n::tr_label($help)))?
|
$(.backing_on_hover_text(crate::i18n::tr_label($help)))?
|
||||||
};
|
};
|
||||||
(@control $app:ident, $ui:ident, $field:ident, check_tr($label:literal)) => {
|
(@control $app:ident, $ui:ident, $field:ident, check_tr($label:literal)) => {
|
||||||
ui_kit::checkbox($ui, crate::i18n::tr($label), &mut $app.preferences.$field)
|
ui_kit::checkbox($ui, crate::i18n::tr($label), &mut $app.preferences.$field)
|
||||||
@@ -2019,8 +2020,8 @@ fn theme_row(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
let tile =
|
let tile = ui_kit::theme_import_tile(ui, app.tr_label("Import…"), app.tr("prefs.theme_file"))
|
||||||
ui_kit::theme_import_tile(ui, app.tr_label("Import…"), app.tr("prefs.theme_file")).on_hover_text(app.tr("prefs.theme_import_hint"));
|
.backing_on_hover_text(app.tr("prefs.theme_import_hint"));
|
||||||
import_tile = Some(tile.rect);
|
import_tile = Some(tile.rect);
|
||||||
if tile.clicked() {
|
if tile.clicked() {
|
||||||
action = Some("import");
|
action = Some("import");
|
||||||
@@ -2049,7 +2050,7 @@ fn theme_row(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
] {
|
] {
|
||||||
if let Some(rect) = crate::scenario::rect_of(&format!("prefs:carousel:{}", crate::i18n::tr(label))) {
|
if let Some(rect) = crate::scenario::rect_of(&format!("prefs:carousel:{}", crate::i18n::tr(label))) {
|
||||||
crate::scenario::publish_target(handle, rect);
|
crate::scenario::publish_target(handle, rect);
|
||||||
ui.interact(rect, ui.make_persistent_id((handle, "help")), egui::Sense::hover()).on_hover_text(app.tr(help));
|
ui.interact(rect, ui.make_persistent_id((handle, "help")), egui::Sense::hover()).backing_on_hover_text(app.tr(help));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2132,7 +2133,7 @@ fn follow_row(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
if !path.is_empty() {
|
if !path.is_empty() {
|
||||||
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
|
||||||
ui_kit::settings_inline_note(ui, &name);
|
ui_kit::settings_inline_note(ui, &name);
|
||||||
ui_kit::help_icon(ui, app.tr(if launch { "prefs.theme_follow_launch" } else { "prefs.theme_follow_note" })).on_hover_text(&path);
|
ui_kit::help_icon(ui, app.tr(if launch { "prefs.theme_follow_launch" } else { "prefs.theme_follow_note" })).backing_on_hover_text(&path);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if let Some(error) = error.filter(|_| !path.is_empty()) {
|
if let Some(error) = error.filter(|_| !path.is_empty()) {
|
||||||
@@ -2715,7 +2716,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
|||||||
Some((Tokens::get().space_24 * 2.0, crate::settings_profiles::sidebar)),
|
Some((Tokens::get().space_24 * 2.0, crate::settings_profiles::sidebar)),
|
||||||
|ui| {
|
|ui| {
|
||||||
let defaults = ui_kit::button(ui, crate::i18n::tr("prefs.restore_page_defaults"), ui_kit::Button::Ghost)
|
let defaults = ui_kit::button(ui, crate::i18n::tr("prefs.restore_page_defaults"), ui_kit::Button::Ghost)
|
||||||
.on_hover_text(crate::i18n::tr("ui.reset_the_settings_on_this_page"));
|
.backing_on_hover_text(crate::i18n::tr("ui.reset_the_settings_on_this_page"));
|
||||||
crate::scenario::publish_target("prefs:defaults", defaults.rect);
|
crate::scenario::publish_target("prefs:defaults", defaults.rect);
|
||||||
if defaults.clicked() {
|
if defaults.clicked() {
|
||||||
button = Some("defaults");
|
button = Some("defaults");
|
||||||
@@ -2892,7 +2893,7 @@ fn nav_bridge(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
(None, true) => Some((app.tr("ui.nav_bridge_waiting").to_owned(), true)),
|
(None, true) => Some((app.tr("ui.nav_bridge_waiting").to_owned(), true)),
|
||||||
};
|
};
|
||||||
ui.with_layout(egui::Layout::left_to_right(egui::Align::Min).with_main_wrap(true), |ui| {
|
ui.with_layout(egui::Layout::left_to_right(egui::Align::Min).with_main_wrap(true), |ui| {
|
||||||
ui_kit::checkbox(ui, &label, &mut app.preferences.nav_bridge_enabled).on_hover_text(&tip);
|
ui_kit::checkbox(ui, &label, &mut app.preferences.nav_bridge_enabled).backing_on_hover_text(&tip);
|
||||||
if let Some((status, waiting)) = &status {
|
if let Some((status, waiting)) = &status {
|
||||||
ui_kit::settings_state_badge(ui, status, if *waiting { ui_kit::SettingsBadgeTone::Warning } else { ui_kit::SettingsBadgeTone::Success });
|
ui_kit::settings_state_badge(ui, status, if *waiting { ui_kit::SettingsBadgeTone::Warning } else { ui_kit::SettingsBadgeTone::Success });
|
||||||
}
|
}
|
||||||
@@ -3462,6 +3463,69 @@ fn active_theme_file(app: &SolveApp) -> Option<&crate::theme_files::ThemeFile> {
|
|||||||
.or_else(|| crate::theme_files::find(&app.preferences.theme, &app.preferences.custom_themes))
|
.or_else(|| crate::theme_files::find(&app.preferences.theme, &app.preferences.custom_themes))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The single runtime decision used by controls, ui.prefs and renderer activation. It never
|
||||||
|
/// changes saved intent or caches accessibility/hardware state in palette tokens.
|
||||||
|
pub(crate) fn blur_availability(app: &SolveApp) -> crate::theme_options::Availability {
|
||||||
|
blur_availability_for(app, active_theme_file(app).is_some_and(crate::theme_options::declares_blur))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn blur_availability_for(app: &SolveApp, offered: bool) -> crate::theme_options::Availability {
|
||||||
|
use crate::theme_options::Availability::{Available, Unavailable};
|
||||||
|
let reduced = app.services.reduce_transparency.as_ref().and_then(|read| read(offered));
|
||||||
|
if !offered && app.blur_session.harness_radius.is_none() {
|
||||||
|
return Available;
|
||||||
|
}
|
||||||
|
if let Some(reason) = app.blur_session.harness_unavailable {
|
||||||
|
return Unavailable(reason);
|
||||||
|
}
|
||||||
|
// State owns CPU policy, including the explicit cfg(test) rendering fixture. The
|
||||||
|
// adapter identity stays real; production CPU capability can never be Available.
|
||||||
|
let capability = app.blur.capability();
|
||||||
|
if app.blur.software_adapter() && capability != crate::blur::Capability::Available {
|
||||||
|
return Unavailable("prefs.blur_software");
|
||||||
|
}
|
||||||
|
if cfg!(target_arch = "wasm32") {
|
||||||
|
return Unavailable("prefs.blur_web");
|
||||||
|
}
|
||||||
|
if reduced == Some(true) {
|
||||||
|
return Unavailable("prefs.blur_reduce_transparency");
|
||||||
|
}
|
||||||
|
if app.home.open || crate::render_workspace::result_active(app) {
|
||||||
|
return Unavailable("prefs.blur_source");
|
||||||
|
}
|
||||||
|
match capability {
|
||||||
|
crate::blur::Capability::Available => Available,
|
||||||
|
crate::blur::Capability::Unavailable(_) => Unavailable("prefs.blur_device"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn prepare_blur(app: &mut SolveApp, ctx: &egui::Context) {
|
||||||
|
if ctx.viewport_id() != egui::ViewportId::ROOT {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let intent = crate::theme::blur_intent(ctx);
|
||||||
|
let radius = app.blur_session.harness_radius.unwrap_or(intent.radius);
|
||||||
|
// Disabled frames need only cached intent and atomic OS observation. Explicit UI/API
|
||||||
|
// queries still report capabilities at radius zero; painting need not probe the GPU.
|
||||||
|
let availability = if radius > 0.0 {
|
||||||
|
blur_availability_for(app, intent.offered)
|
||||||
|
} else {
|
||||||
|
if let Some(read) = &app.services.reduce_transparency {
|
||||||
|
read(intent.offered);
|
||||||
|
}
|
||||||
|
crate::theme_options::Availability::Available
|
||||||
|
};
|
||||||
|
// Resolve raw/default intent separately so temporary unavailability never drives the
|
||||||
|
// renderer through radius=0 and clears a latched device/allocation failure.
|
||||||
|
let available = availability == crate::theme_options::Availability::Available;
|
||||||
|
app.blur_session.sample(ctx, intent.revision, radius, available, app.blur.painted_frame());
|
||||||
|
app.blur.set_available(available);
|
||||||
|
app.blur.set_tier_cap(app.blur_session.guard.cap);
|
||||||
|
if let Err(error) = app.blur.set_radius(radius) {
|
||||||
|
log::warn!("theme blur: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn followed_theme<'a>(prefs: &Prefs, follow: &'a crate::theme_files::Follow) -> Option<&'a crate::theme_files::ThemeFile> {
|
fn followed_theme<'a>(prefs: &Prefs, follow: &'a crate::theme_files::Follow) -> Option<&'a crate::theme_files::ThemeFile> {
|
||||||
(!follow.path(&prefs.theme_follow).is_empty()).then(|| follow.theme()).flatten()
|
(!follow.path(&prefs.theme_follow).is_empty()).then(|| follow.theme()).flatten()
|
||||||
}
|
}
|
||||||
@@ -3529,6 +3593,7 @@ fn theme_option_rows<'a>(
|
|||||||
file: &'a crate::theme_files::ThemeFile,
|
file: &'a crate::theme_files::ThemeFile,
|
||||||
language: &str,
|
language: &str,
|
||||||
providers: &[crate::theme_options::Provider],
|
providers: &[crate::theme_options::Provider],
|
||||||
|
blur: crate::theme_options::Availability,
|
||||||
) -> Vec<ui_kit::ThemeOptionRow<'a>> {
|
) -> Vec<ui_kit::ThemeOptionRow<'a>> {
|
||||||
use crate::theme_options::{Availability, offered, provider};
|
use crate::theme_options::{Availability, offered, provider};
|
||||||
let locale = crate::i18n::Locale::for_language(language);
|
let locale = crate::i18n::Locale::for_language(language);
|
||||||
@@ -3538,6 +3603,7 @@ fn theme_option_rows<'a>(
|
|||||||
let provider = provider(&definition.id, providers);
|
let provider = provider(&definition.id, providers);
|
||||||
ui_kit::ThemeOptionRow {
|
ui_kit::ThemeOptionRow {
|
||||||
definition,
|
definition,
|
||||||
|
blur: definition.id == "blur",
|
||||||
label: provider
|
label: provider
|
||||||
.map(|p| locale.text(p.label).to_owned())
|
.map(|p| locale.text(p.label).to_owned())
|
||||||
.unwrap_or_else(|| theme_option_text(&definition.label, language, &definition.id)),
|
.unwrap_or_else(|| theme_option_text(&definition.label, language, &definition.id)),
|
||||||
@@ -3548,22 +3614,50 @@ fn theme_option_rows<'a>(
|
|||||||
}
|
}
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
},
|
},
|
||||||
reason: provider.and_then(|p| match (p.availability)() {
|
reason: match crate::theme_options::availability(file, &definition.id, providers, blur) {
|
||||||
Availability::Available => None,
|
Availability::Available => None,
|
||||||
Availability::Unavailable(reason) => Some(locale.text(reason).to_owned()),
|
Availability::Unavailable(reason) => Some(locale.text(reason).to_owned()),
|
||||||
}),
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn theme_options_row(app: &mut SolveApp, ui: &mut egui::Ui) {
|
fn theme_options_row(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||||
let Some(file) = active_theme_file(app) else { return };
|
let availability = blur_availability(app);
|
||||||
let rows = theme_option_rows(file, crate::i18n::effective_language(&app.preferences.language), crate::theme_options::PROVIDERS);
|
let Some(file) = active_theme_file(app).cloned() else { return };
|
||||||
|
let (mut values, notices) = effective_theme_options(&app.preferences, &file);
|
||||||
|
// Notices precede the empty-control return: newer requirements intentionally have no rows.
|
||||||
|
let mut problems: Vec<_> = notices
|
||||||
|
.iter()
|
||||||
|
.map(|notice| {
|
||||||
|
let raw = app.preferences.theme_options.get(&file.id).and_then(|v| v.get(¬ice.id));
|
||||||
|
(notice.id.clone(), format!("{:?}:{raw:?}", notice.kind), crate::i18n::format("prefs.theme_option_invalid", &[("option", ¬ice.id)]))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if file.needs_newer() {
|
||||||
|
problems.push(("newer".into(), file.requires.to_string(), app.tr("prefs.theme_options_newer").to_owned()));
|
||||||
|
}
|
||||||
|
for (id, identity, text) in problems {
|
||||||
|
let key = theme_notice_key(&file.id, &id, &identity);
|
||||||
|
if !app.blur_session.dismissed(key) && ui_kit::theme_option_notice(ui, &id, &text, false).1 {
|
||||||
|
app.blur_session.dismiss(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if app.blur_session.guard.notice {
|
||||||
|
let (undo, dismiss) = ui_kit::theme_option_notice(ui, "guard", app.tr("prefs.blur_guard"), true);
|
||||||
|
if undo {
|
||||||
|
app.blur_session.guard.undo();
|
||||||
|
ui.ctx().request_repaint();
|
||||||
|
}
|
||||||
|
if dismiss {
|
||||||
|
app.blur_session.guard.notice = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let rows = theme_option_rows(&file, crate::i18n::effective_language(&app.preferences.language), crate::theme_options::PROVIDERS, availability);
|
||||||
if rows.is_empty() {
|
if rows.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let (mut values, _) = effective_theme_options(&app.preferences, file);
|
|
||||||
let before = values.clone();
|
let before = values.clone();
|
||||||
let reset = ui_kit::settings_card(ui, |ui| ui_kit::theme_options_list(ui, &file.id, &file.name, &rows, &mut values)).inner;
|
let reset = ui_kit::settings_card(ui, |ui| ui_kit::theme_options_list(ui, &file.id, &file.name, &rows, &mut values)).inner;
|
||||||
let changes: BTreeMap<_, _> = values.into_iter().filter(|(id, value)| before.get(id) != Some(value)).collect();
|
let changes: BTreeMap<_, _> = values.into_iter().filter(|(id, value)| before.get(id) != Some(value)).collect();
|
||||||
@@ -3578,6 +3672,13 @@ fn theme_options_row(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn theme_notice_key(theme: &str, problem: &str, raw: &str) -> u64 {
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||||||
|
(theme, problem, raw).hash(&mut hash);
|
||||||
|
hash.finish()
|
||||||
|
}
|
||||||
|
|
||||||
/// Only explicitly supplied entries are checked. Stale saved entries survive unrelated changes.
|
/// Only explicitly supplied entries are checked. Stale saved entries survive unrelated changes.
|
||||||
fn merge_theme_options(prefs: &mut Prefs, followed: Option<&crate::theme_files::ThemeFile>, update: &serde_json::Value) -> Result<(), String> {
|
fn merge_theme_options(prefs: &mut Prefs, followed: Option<&crate::theme_files::ThemeFile>, update: &serde_json::Value) -> Result<(), String> {
|
||||||
use crate::theme_files::{OptionKind, OptionValue};
|
use crate::theme_files::{OptionKind, OptionValue};
|
||||||
@@ -3644,13 +3745,14 @@ fn theme_options_reply(app: &SolveApp) -> solidcraft_engine::schema::ui_prefs::T
|
|||||||
.to_string()
|
.to_string()
|
||||||
};
|
};
|
||||||
let (values, notices) = effective_theme_options(&app.preferences, file);
|
let (values, notices) = effective_theme_options(&app.preferences, file);
|
||||||
|
let blur = blur_availability(app);
|
||||||
ThemeOptionsReply {
|
ThemeOptionsReply {
|
||||||
theme_option_defs: crate::theme_files::ordered_options(file)
|
theme_option_defs: crate::theme_files::ordered_options(file)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|o| {
|
.map(|o| {
|
||||||
let standard = crate::theme_options::standard(&o.id);
|
let standard = crate::theme_options::standard(&o.id);
|
||||||
let provider = crate::theme_options::provider(&o.id, crate::theme_options::PROVIDERS);
|
let provider = crate::theme_options::provider(&o.id, crate::theme_options::PROVIDERS);
|
||||||
let availability = provider.map(|p| (p.availability)());
|
let availability = crate::theme_options::availability(file, &o.id, crate::theme_options::PROVIDERS, blur);
|
||||||
ThemeOptionDef {
|
ThemeOptionDef {
|
||||||
id: o.id.clone(),
|
id: o.id.clone(),
|
||||||
kind: match &o.kind {
|
kind: match &o.kind {
|
||||||
@@ -3669,11 +3771,10 @@ fn theme_options_reply(app: &SolveApp) -> solidcraft_engine::schema::ui_prefs::T
|
|||||||
advanced: o.advanced,
|
advanced: o.advanced,
|
||||||
section: o.section.clone(),
|
section: o.section.clone(),
|
||||||
standard,
|
standard,
|
||||||
available: !standard || availability == Some(crate::theme_options::Availability::Available),
|
available: availability == crate::theme_options::Availability::Available,
|
||||||
reason: match availability {
|
reason: match availability {
|
||||||
Some(crate::theme_options::Availability::Unavailable(reason)) => Some(app.tr(reason).to_owned()),
|
crate::theme_options::Availability::Unavailable(reason) => Some(app.tr(reason).to_owned()),
|
||||||
None if standard => Some("not implemented".into()),
|
crate::theme_options::Availability::Available => None,
|
||||||
_ => None,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -6829,12 +6930,12 @@ mod tests {
|
|||||||
if count == 0 {
|
if count == 0 {
|
||||||
let viewport = crate::scenario::rect_of("prefs:section:viewport_logo").unwrap();
|
let viewport = crate::scenario::rect_of("prefs:section:viewport_logo").unwrap();
|
||||||
let mut unknown = theme_options_fixture(base, count);
|
let mut unknown = theme_options_fixture(base, count);
|
||||||
unknown["options"] = json!([{"id":"blur","type":"range","default":0}]);
|
unknown["options"] = json!([]);
|
||||||
h.call("ui.prefs", json!({"theme_import":unknown}));
|
h.call("ui.prefs", json!({"theme_import":unknown}));
|
||||||
h.frames(2);
|
h.frames(2);
|
||||||
assert!(crate::scenario::rect_of("prefs:theme_options_header").is_none());
|
assert!(crate::scenario::rect_of("prefs:theme_options_header").is_none());
|
||||||
assert!(crate::scenario::rect_of(&format!("prefs:theme_options_badge:{id}")).is_none());
|
assert!(crate::scenario::rect_of(&format!("prefs:theme_options_badge:{id}")).is_none());
|
||||||
assert_eq!(crate::scenario::rect_of("prefs:section:viewport_logo").unwrap(), viewport, "unknown providers add no gap or notice");
|
assert_eq!(crate::scenario::rect_of("prefs:section:viewport_logo").unwrap(), viewport, "empty declarations add no gap or notice");
|
||||||
}
|
}
|
||||||
if count == 12 {
|
if count == 12 {
|
||||||
assert!(crate::scenario::rect_of("prefs:theme_option:outline").is_none());
|
assert!(crate::scenario::rect_of("prefs:theme_option:outline").is_none());
|
||||||
@@ -7043,11 +7144,11 @@ mod tests {
|
|||||||
{"id":"opacity","type":"range","default":100}
|
{"id":"opacity","type":"range","default":100}
|
||||||
]}))
|
]}))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let rows = theme_option_rows(&file, "de", crate::theme_options::PROVIDERS);
|
let rows = theme_option_rows(&file, "de", crate::theme_options::PROVIDERS, Availability::Available);
|
||||||
assert_eq!(rows.iter().map(|r| r.definition.id.as_str()).collect::<Vec<_>>(), ["opacity", "tab_colours", "tab_fade_ms", "custom"]);
|
assert_eq!(rows.iter().map(|r| r.definition.id.as_str()).collect::<Vec<_>>(), ["opacity", "blur", "tab_colours", "tab_fade_ms", "custom"]);
|
||||||
assert_eq!(rows[0].label, "Deckkraft");
|
assert_eq!(rows[0].label, "Deckkraft");
|
||||||
assert_eq!(rows[3].label, "Eigene");
|
assert_eq!(rows[4].label, "Eigene");
|
||||||
assert_eq!(theme_option_rows(&file, "ja", crate::theme_options::PROVIDERS)[3].label, "Custom", "English fallback");
|
assert_eq!(theme_option_rows(&file, "ja", crate::theme_options::PROVIDERS, Availability::Available)[4].label, "Custom", "English fallback");
|
||||||
let providers = [Provider {
|
let providers = [Provider {
|
||||||
id: "blur",
|
id: "blur",
|
||||||
label: "prefs.theme_option_opacity",
|
label: "prefs.theme_option_opacity",
|
||||||
@@ -7055,7 +7156,7 @@ mod tests {
|
|||||||
availability: || Availability::Unavailable("prefs.theme_options_unavailable"),
|
availability: || Availability::Unavailable("prefs.theme_options_unavailable"),
|
||||||
apply: |_, _| {},
|
apply: |_, _| {},
|
||||||
}];
|
}];
|
||||||
let rows = theme_option_rows(&file, "en", &providers);
|
let rows = theme_option_rows(&file, "en", &providers, Availability::Available);
|
||||||
assert_eq!(rows.iter().map(|r| r.definition.id.as_str()).collect::<Vec<_>>(), ["blur", "custom"]);
|
assert_eq!(rows.iter().map(|r| r.definition.id.as_str()).collect::<Vec<_>>(), ["blur", "custom"]);
|
||||||
assert_eq!(rows[0].reason.as_deref(), Some("Unavailable on this device"));
|
assert_eq!(rows[0].reason.as_deref(), Some("Unavailable on this device"));
|
||||||
// The internal fixture goes through the real kit. No fake provider is shipped.
|
// The internal fixture goes through the real kit. No fake provider is shipped.
|
||||||
@@ -7091,6 +7192,179 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blur_slider_reserves_readout_meter_and_disabled_reason() {
|
||||||
|
use crate::theme_files::OptionValue;
|
||||||
|
use crate::theme_options::Availability;
|
||||||
|
let source: serde_json::Value = serde_json::from_str(include_str!("../tests/fixtures/theme-blur.json")).unwrap();
|
||||||
|
let file = crate::theme_files::from_value(&source).unwrap();
|
||||||
|
for dark in [false, true] {
|
||||||
|
crate::theme::set_custom(None);
|
||||||
|
crate::theme::set_dark(dark);
|
||||||
|
for available in [Availability::Available, Availability::Unavailable("prefs.theme_options_unavailable")] {
|
||||||
|
let rows = theme_option_rows(&file, "en", crate::theme_options::PROVIDERS, available);
|
||||||
|
for radius in [0.0, 1.0, 8.0, 9.0, 18.0, 33.0, 40.0, 18.25] {
|
||||||
|
let ctx = crate::fonts::test_context();
|
||||||
|
let mut values = crate::theme_files::effective_values(&file, &BTreeMap::new()).0;
|
||||||
|
values.insert("blur".into(), OptionValue::Num(radius));
|
||||||
|
let before = values.clone();
|
||||||
|
let mut output = egui::FullOutput::default();
|
||||||
|
for _ in 0..2 {
|
||||||
|
crate::scenario::clear_handles();
|
||||||
|
output = crate::test_support::frame(
|
||||||
|
&ctx,
|
||||||
|
egui::RawInput {
|
||||||
|
screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1200.0, 800.0))),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
|ui| {
|
||||||
|
ui_kit::scope(ui, |ui| {
|
||||||
|
ui_kit::theme_options_list(ui, &file.id, &file.name, &rows, &mut values);
|
||||||
|
})
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(values, before, "drawing must preserve fractional intent");
|
||||||
|
let meter = crate::scenario::rect_of("prefs:blur_cost").unwrap();
|
||||||
|
let readout = crate::scenario::rect_of("prefs:theme_option_readout:blur").unwrap();
|
||||||
|
let slider = crate::scenario::rect_of("dialog:slider_track:Blur behind panels").unwrap();
|
||||||
|
assert!(slider.width() > 300.0, "standalone blur fills remaining row width");
|
||||||
|
assert!(slider.right() <= readout.left() && readout.right() <= meter.left());
|
||||||
|
assert_eq!(crate::scenario::rect_of("prefs:theme_option_reason:blur").is_some(), available != Availability::Available);
|
||||||
|
let mut heights = Vec::new();
|
||||||
|
for clipped in &output.shapes {
|
||||||
|
crate::test_support::each_shape(&clipped.shape, &mut |shape| {
|
||||||
|
if let egui::Shape::Rect(rect) = shape
|
||||||
|
&& meter.contains_rect(rect.rect)
|
||||||
|
{
|
||||||
|
heights.push(rect.rect.height());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert_eq!(heights, if radius == 0.0 { vec![2.0] } else { vec![4.0, 6.0, 8.0, 11.0, 14.0] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blur_availability_preserves_saved_intent_and_parent_state() {
|
||||||
|
use crate::theme_options::{Availability, Provider};
|
||||||
|
let mut h = crate::scenario::Harness::new();
|
||||||
|
h.app.home.open = false;
|
||||||
|
let source = json!({"format":"solidcraft-theme/1","name":"Blur parent","base":"dark","options":[
|
||||||
|
{"id":"enabled","type":"bool","default":true}, {"id":"blur","type":"range","default":0,"parent":"enabled"},
|
||||||
|
{"id":"amount","type":"range","default":5,"min":0,"max":10,"step":1,"parent":"enabled"}
|
||||||
|
]});
|
||||||
|
control(&mut h.app, &json!({"theme_import":source,"theme_options":{"blur-parent":{"blur":18.25,"amount":7.5}}})).unwrap();
|
||||||
|
let file = active_theme_file(&h.app).unwrap().clone();
|
||||||
|
assert_eq!(file.options.iter().find(|o| o.id == "blur").unwrap().parent, None, "reserved blur ignores authored parenting");
|
||||||
|
assert_eq!(file.options.iter().find(|o| o.id == "amount").unwrap().parent.as_deref(), Some("enabled"));
|
||||||
|
let saved = h.app.preferences.theme_options.clone();
|
||||||
|
let values = effective_theme_options(&h.app.preferences, &file).0;
|
||||||
|
for reason in ["prefs.blur_software", "prefs.blur_web", "prefs.blur_reduce_transparency"] {
|
||||||
|
assert_eq!(crate::theme_options::blur_radius(&file, &values, Availability::Unavailable(reason)), 0.0);
|
||||||
|
assert_eq!(crate::theme_options::blur_radius(&file, &values, Availability::Available), 18.25);
|
||||||
|
}
|
||||||
|
assert_eq!(h.app.preferences.theme_options, saved);
|
||||||
|
let providers = [
|
||||||
|
Provider { id: "enabled", label: "", help: "", availability: || Availability::Unavailable("prefs.blur_software"), apply: |_, _| {} },
|
||||||
|
Provider {
|
||||||
|
id: "blur",
|
||||||
|
label: "prefs.theme_option_blur",
|
||||||
|
help: "prefs.theme_option_blur_help",
|
||||||
|
availability: || Availability::Available,
|
||||||
|
apply: |_, _| {},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
assert_eq!(
|
||||||
|
crate::theme_options::availability(&file, "amount", &providers, Availability::Available),
|
||||||
|
Availability::Unavailable("prefs.blur_software")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
crate::theme_options::availability(&file, "blur", &providers, Availability::Available),
|
||||||
|
Availability::Available,
|
||||||
|
"standard blur remains independent of the unavailable custom parent"
|
||||||
|
);
|
||||||
|
let rows = theme_option_rows(&file, "en", &providers, Availability::Available);
|
||||||
|
let parent = rows.iter().find(|r| r.definition.id == "enabled").unwrap();
|
||||||
|
let child = rows.iter().find(|r| r.definition.id == "amount").unwrap();
|
||||||
|
let blur = rows.iter().find(|r| r.definition.id == "blur").unwrap();
|
||||||
|
assert!(parent.reason.is_some());
|
||||||
|
assert_eq!(child.reason, parent.reason, "the genuine custom child inherits its parent's unavailable reason");
|
||||||
|
assert!(blur.reason.is_none(), "the reserved blur control is unaffected by custom-parent availability");
|
||||||
|
let reduced = std::rc::Rc::new(std::cell::Cell::new(true));
|
||||||
|
let reading = reduced.clone();
|
||||||
|
h.app.services.reduce_transparency = Some(Box::new(move |_| Some(reading.get())));
|
||||||
|
assert_eq!(blur_availability(&h.app), Availability::Unavailable("prefs.blur_reduce_transparency"));
|
||||||
|
let reply = control(&mut h.app, &json!({})).unwrap();
|
||||||
|
assert_eq!(reply["theme_option_values"]["blur"], 18.25);
|
||||||
|
h.frames(2);
|
||||||
|
assert_eq!(h.app.blur.radius(), 18.25, "OS unavailability preserves requested intent");
|
||||||
|
assert_eq!(h.app.preferences.theme_options, saved);
|
||||||
|
assert_eq!(h.app.blur.counters().textures_created, 0);
|
||||||
|
assert_eq!(h.app.blur.counters().markers_emitted, 0);
|
||||||
|
reduced.set(false);
|
||||||
|
assert_ne!(blur_availability(&h.app), Availability::Unavailable("prefs.blur_reduce_transparency"));
|
||||||
|
h.frames(2);
|
||||||
|
assert_eq!(h.app.blur.radius(), 18.25, "renderer retains raw intent even without a GPU");
|
||||||
|
assert_eq!(h.app.blur.counters().textures_created, 0);
|
||||||
|
control(&mut h.app, &json!({"theme_options":{"blur-parent":{"enabled":false}}})).unwrap();
|
||||||
|
h.frames(2);
|
||||||
|
assert_eq!(h.app.blur.radius(), 18.25, "an inactive custom parent cannot turn independent standard blur off");
|
||||||
|
assert_eq!(crate::theme::blur_intent(&h.ctx).radius, 18.25);
|
||||||
|
let reply = control(&mut h.app, &json!({})).unwrap();
|
||||||
|
assert_eq!(reply["theme_option_values"]["enabled"], false);
|
||||||
|
assert_eq!(reply["theme_option_values"]["amount"], 7.5, "an inactive custom child retains its saved value");
|
||||||
|
assert_eq!(h.app.preferences.theme_options["blur-parent"]["blur"], 18.25);
|
||||||
|
control(&mut h.app, &json!({"theme_options":{"blur-parent":{"enabled":true}}})).unwrap();
|
||||||
|
h.frames(2);
|
||||||
|
assert_eq!(h.app.blur.radius(), 18.25);
|
||||||
|
assert_eq!(h.app.preferences.theme_options["blur-parent"]["amount"], 7.5);
|
||||||
|
let counts = h.app.blur.counters();
|
||||||
|
assert_eq!((counts.textures_created, counts.markers_emitted, counts.markers_drawn, counts.live_bytes), (0, 0, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blur_notices_dismiss_by_problem_identity_and_preserve_raw_json() {
|
||||||
|
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!({"theme_import":option_theme_file("Notice blur"),"open":"Interface"}));
|
||||||
|
h.app.preferences.theme_options.insert("notice-blur".into(), BTreeMap::from([("blur".into(), json!({"future":18}))]));
|
||||||
|
h.frames(3);
|
||||||
|
assert!(crate::scenario::rect_of("prefs:theme_notice:blur").is_some());
|
||||||
|
h.step(&json!({"click":{"handle":"prefs:theme_notice_dismiss:blur"}})).unwrap();
|
||||||
|
h.frames(2);
|
||||||
|
assert!(crate::scenario::rect_of("prefs:theme_notice:blur").is_none());
|
||||||
|
assert_eq!(h.app.preferences.theme_options["notice-blur"]["blur"], json!({"future":18}));
|
||||||
|
let reply = control(&mut h.app, &json!({})).unwrap();
|
||||||
|
assert_eq!(reply["theme_option_notices"], json!([{"id":"blur","kind":"wrong_type"}]));
|
||||||
|
h.app.preferences.theme_options.get_mut("notice-blur").unwrap().insert("blur".into(), json!({"future":19}));
|
||||||
|
h.frames(2);
|
||||||
|
assert!(crate::scenario::rect_of("prefs:theme_notice:blur").is_some(), "changed raw value must reappear");
|
||||||
|
h.call(
|
||||||
|
"ui.prefs",
|
||||||
|
json!({"theme_import":{"format":"solidcraft-theme/1","name":"Future blur","base":"dark","requires":2,
|
||||||
|
"options":[{"id":"blur","type":"range","default":18}]}}),
|
||||||
|
);
|
||||||
|
h.frames(2);
|
||||||
|
assert!(crate::scenario::rect_of("prefs:theme_notice:newer").is_some());
|
||||||
|
assert!(crate::scenario::rect_of("prefs:theme_options_header").is_none());
|
||||||
|
h.step(&json!({"click":{"handle":"prefs:theme_notice_dismiss:newer"}})).unwrap();
|
||||||
|
h.frames(2);
|
||||||
|
assert!(crate::scenario::rect_of("prefs:theme_notice:newer").is_none());
|
||||||
|
assert!(active_theme_file(&h.app).unwrap().options.is_empty(), "unsupported tables were discarded, never auto-restored");
|
||||||
|
h.app.preferences.custom_themes.iter_mut().find(|f| f.id == "future-blur").unwrap().requires = 3;
|
||||||
|
h.frames(2);
|
||||||
|
assert!(crate::scenario::rect_of("prefs:theme_notice:newer").is_some());
|
||||||
|
for identity in 0..80 {
|
||||||
|
h.app.blur_session.dismiss(identity);
|
||||||
|
}
|
||||||
|
assert!(!h.app.blur_session.dismissed(0), "dismissal storage is bounded");
|
||||||
|
assert!(h.app.blur_session.dismissed(79));
|
||||||
|
}
|
||||||
|
|
||||||
fn option_theme_file(name: &str) -> serde_json::Value {
|
fn option_theme_file(name: &str) -> serde_json::Value {
|
||||||
json!({
|
json!({
|
||||||
"format": "solidcraft-theme/1", "name": name, "base": "dark", "colors": {"panel": "#112233"},
|
"format": "solidcraft-theme/1", "name": name, "base": "dark", "colors": {"panel": "#112233"},
|
||||||
@@ -7572,7 +7846,8 @@ mod tests {
|
|||||||
assert_eq!(reply.options.theme_option_defs.iter().map(|o| o.id.as_str()).collect::<Vec<_>>(), ["blur", "enabled", "amount", "style"]);
|
assert_eq!(reply.options.theme_option_defs.iter().map(|o| o.id.as_str()).collect::<Vec<_>>(), ["blur", "enabled", "amount", "style"]);
|
||||||
let defs = &reply.options.theme_option_defs;
|
let defs = &reply.options.theme_option_defs;
|
||||||
assert!(defs[0].standard && !defs[0].available);
|
assert!(defs[0].standard && !defs[0].available);
|
||||||
assert_eq!(defs[0].reason.as_deref(), Some("not implemented"));
|
let crate::theme_options::Availability::Unavailable(reason) = blur_availability(&h.app) else { panic!("headless fixture has no GPU") };
|
||||||
|
assert_eq!(defs[0].reason.as_deref(), Some(crate::i18n::Locale::for_language("de").text(reason)));
|
||||||
assert_eq!((&defs[1].label, &defs[1].help), (&"Aktiv".to_string(), &"Farben verwenden".to_string()));
|
assert_eq!((&defs[1].label, &defs[1].help), (&"Aktiv".to_string(), &"Farben verwenden".to_string()));
|
||||||
assert!(defs[1].available && defs[1].reason.is_none());
|
assert!(defs[1].available && defs[1].reason.is_none());
|
||||||
assert!(defs[2].advanced && defs[2].section.as_deref() == Some("Details"));
|
assert!(defs[2].advanced && defs[2].section.as_deref() == Some("Details"));
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
),
|
),
|
||||||
] {
|
] {
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
let response = setting(app, ui).on_hover_text(app.tr(help));
|
let response = setting(app, ui).backing_on_hover_text(app.tr(help));
|
||||||
crate::scenario::publish_target(&format!("prefs:{target}"), response.rect);
|
crate::scenario::publish_target(&format!("prefs:{target}"), response.rect);
|
||||||
crate::scenario::publish_target(&format!("dialog:{legacy}"), response.rect);
|
crate::scenario::publish_target(&format!("dialog:{legacy}"), response.rect);
|
||||||
if !crate::prefs_search::suppress_chrome(ui) {
|
if !crate::prefs_search::suppress_chrome(ui) {
|
||||||
@@ -139,7 +139,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
let response = setting::offer_stand_up(app, ui).on_hover_text(app.tr("prefs.stand_up_note"));
|
let response = setting::offer_stand_up(app, ui).backing_on_hover_text(app.tr("prefs.stand_up_note"));
|
||||||
crate::scenario::publish_target("prefs:offer_stand_up", response.rect);
|
crate::scenario::publish_target("prefs:offer_stand_up", response.rect);
|
||||||
if !crate::prefs_search::suppress_chrome(ui) {
|
if !crate::prefs_search::suppress_chrome(ui) {
|
||||||
ui_kit::help_icon(ui, app.tr("prefs.stand_up_note"));
|
ui_kit::help_icon(ui, app.tr("prefs.stand_up_note"));
|
||||||
@@ -176,7 +176,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
crate::scenario::publish_target("prefs:update_changed_links", response.rect);
|
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);
|
crate::scenario::publish_target("dialog:Update changed linked designs when a tab comes back", response.rect);
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
let response = setting::double_click_components(app, ui).on_hover_text(app.tr("prefs.design_tools.double_click_help"));
|
let response = setting::double_click_components(app, ui).backing_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("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);
|
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) {
|
if !crate::prefs_search::suppress_chrome(ui) {
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
let (single, tip) = (app.tr("ui.slicer_single_instance").to_string(), app.tr("ui.slicer_single_instance_tip").to_string());
|
let (single, tip) = (app.tr("ui.slicer_single_instance").to_string(), app.tr("ui.slicer_single_instance_tip").to_string());
|
||||||
let width = (ui.available_width() - t.space_16 - ui.spacing().item_spacing.x).max(0.0);
|
let width = (ui.available_width() - t.space_16 - ui.spacing().item_spacing.x).max(0.0);
|
||||||
ui.allocate_ui_with_layout(egui::vec2(width, t.control_height), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
ui.allocate_ui_with_layout(egui::vec2(width, t.control_height), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||||
ui_kit::checkbox(ui, &single, &mut app.preferences.slicer_single_instance).on_hover_text(&tip);
|
ui_kit::checkbox(ui, &single, &mut app.preferences.slicer_single_instance).backing_on_hover_text(&tip);
|
||||||
});
|
});
|
||||||
ui_kit::help_icon(ui, &tip);
|
ui_kit::help_icon(ui, &tip);
|
||||||
});
|
});
|
||||||
@@ -135,7 +135,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
let badge = ui_kit::settings_state_badge(ui, &label, tone);
|
let badge = ui_kit::settings_state_badge(ui, &label, tone);
|
||||||
crate::scenario::publish_rect("prefs:crash_count", badge.rect);
|
crate::scenario::publish_rect("prefs:crash_count", badge.rect);
|
||||||
if ui_kit::button(ui, app.tr("prefs.crash_reports"), ui_kit::Button::Normal)
|
if ui_kit::button(ui, app.tr("prefs.crash_reports"), ui_kit::Button::Normal)
|
||||||
.on_hover_text(app.tr("prefs.crash_reports_note"))
|
.backing_on_hover_text(app.tr("prefs.crash_reports_note"))
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
crate::crash_ui::open(app);
|
crate::crash_ui::open(app);
|
||||||
@@ -192,7 +192,7 @@ fn file_size_rows(app: &mut SolveApp, ui: &mut egui::Ui, labels: f32) {
|
|||||||
ui_kit::number_field(ui, value, default..=top, 1.0, "")
|
ui_kit::number_field(ui, value, default..=top, 1.0, "")
|
||||||
})
|
})
|
||||||
.inner
|
.inner
|
||||||
.on_hover_text(&help);
|
.backing_on_hover_text(&help);
|
||||||
crate::scenario::publish_rect(handle, field.rect);
|
crate::scenario::publish_rect(handle, field.rect);
|
||||||
let size = limits::describe(value.saturating_mul(limits::MB));
|
let size = limits::describe(value.saturating_mul(limits::MB));
|
||||||
let note = if *value >= 1024 { format!("{unit} ({size})") } else { unit.to_owned() };
|
let note = if *value >= 1024 { format!("{unit} ({size})") } else { unit.to_owned() };
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
&|p, rect, ink| paint_preset_glyph(p, rect, id, ink),
|
&|p, rect, ink| paint_preset_glyph(p, rect, id, ink),
|
||||||
width,
|
width,
|
||||||
)
|
)
|
||||||
.on_hover_text(app.tr("ui.graphics_preset_help"));
|
.backing_on_hover_text(app.tr("ui.graphics_preset_help"));
|
||||||
crate::scenario::publish_target(&format!("view:graphics_preset:{id}"), response.rect);
|
crate::scenario::publish_target(&format!("view:graphics_preset:{id}"), response.rect);
|
||||||
if id == "performance" {
|
if id == "performance" {
|
||||||
crate::scenario::publish_target("view:graphics_preset", response.rect);
|
crate::scenario::publish_target("view:graphics_preset", response.rect);
|
||||||
@@ -71,7 +71,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
},
|
},
|
||||||
width,
|
width,
|
||||||
)
|
)
|
||||||
.on_hover_text(app.tr("settings.display_quality_note"));
|
.backing_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:display_quality:{}", q.id()), response.rect);
|
||||||
crate::scenario::publish_target(&format!("prefs:option:{label}"), response.rect);
|
crate::scenario::publish_target(&format!("prefs:option:{label}"), response.rect);
|
||||||
if response.clicked() {
|
if response.clicked() {
|
||||||
@@ -111,8 +111,8 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
},
|
},
|
||||||
(t.space_24 * 5.0 + t.space_10).min(ui.max_rect().width()),
|
(t.space_24 * 5.0 + t.space_10).min(ui.max_rect().width()),
|
||||||
)
|
)
|
||||||
.on_hover_text(app.tr("settings.msaa_applies_on_restart"))
|
.backing_on_hover_text(app.tr("settings.msaa_applies_on_restart"))
|
||||||
.on_disabled_hover_text(app.tr("settings.msaa_applies_on_restart"));
|
.backing_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:msaa:{m}"), response.rect);
|
||||||
crate::scenario::publish_target(&format!("prefs:option:{label}"), response.rect);
|
crate::scenario::publish_target(&format!("prefs:option:{label}"), response.rect);
|
||||||
if enabled && response.clicked() {
|
if enabled && response.clicked() {
|
||||||
@@ -154,7 +154,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
crate::scenario::publish_target("prefs:supersampling_adaptive", response.rect);
|
crate::scenario::publish_target("prefs:supersampling_adaptive", response.rect);
|
||||||
crate::scenario::publish_target("dialog:Only when the view is still", 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"));
|
let response = setting::dark_parts_shading(app, ui).backing_on_hover_text(app.tr("prefs.dark_parts_shading_help"));
|
||||||
crate::scenario::publish_target("prefs:dark_parts_shading", response.rect);
|
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_section(ui, "current_view", app.tr("prefs.graphics_page.current_view"), |ui| {
|
||||||
@@ -262,7 +262,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
},
|
},
|
||||||
width,
|
width,
|
||||||
)
|
)
|
||||||
.on_hover_text(help);
|
.backing_on_hover_text(help);
|
||||||
crate::scenario::publish_target(&format!("prefs:{id}"), response.rect);
|
crate::scenario::publish_target(&format!("prefs:{id}"), response.rect);
|
||||||
crate::scenario::publish_target(&format!("dialog:{label}"), response.rect);
|
crate::scenario::publish_target(&format!("dialog:{label}"), response.rect);
|
||||||
if id == "quick_measure" {
|
if id == "quick_measure" {
|
||||||
@@ -285,10 +285,10 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
});
|
});
|
||||||
ui.add_enabled_ui(app.preferences.show_frame_rate, |ui| {
|
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)
|
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"));
|
.backing_on_hover_text(app.tr("settings.frame_rate_details_note"));
|
||||||
crate::scenario::publish_target("prefs:frame_rate_details", response.rect);
|
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"));
|
let profile = setting::profile_operations(app, ui).backing_on_hover_text(app.tr("prefs.profile_operations_note"));
|
||||||
crate::scenario::publish_target("prefs:profile_operations", profile.rect);
|
crate::scenario::publish_target("prefs:profile_operations", profile.rect);
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
ui_kit::settings_inline_note(ui, app.tr("prefs.graphics_page.status_chips"));
|
ui_kit::settings_inline_note(ui, app.tr("prefs.graphics_page.status_chips"));
|
||||||
@@ -310,7 +310,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
if id == "viewport_section_status" { crate::i18n::tr_label(label) } else { crate::i18n::tr(label) },
|
if id == "viewport_section_status" { crate::i18n::tr_label(label) } else { crate::i18n::tr(label) },
|
||||||
*value,
|
*value,
|
||||||
)
|
)
|
||||||
.on_hover_text(crate::i18n::tr(help));
|
.backing_on_hover_text(crate::i18n::tr(help));
|
||||||
crate::scenario::publish_target(&format!("prefs:{id}"), response.rect);
|
crate::scenario::publish_target(&format!("prefs:{id}"), response.rect);
|
||||||
if response.clicked() {
|
if response.clicked() {
|
||||||
*value = !*value;
|
*value = !*value;
|
||||||
@@ -320,7 +320,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
});
|
});
|
||||||
ui_kit::settings_section(ui, "surfaces_edges", app.tr("prefs.graphics_page.surfaces_edges"), |ui| {
|
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)
|
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"));
|
.backing_on_hover_text(app.tr("ui.cosmetic_thread_markings_help"));
|
||||||
crate::scenario::publish_target("prefs:cosmetic_threads", response.rect);
|
crate::scenario::publish_target("prefs:cosmetic_threads", response.rect);
|
||||||
setting::sketch_halo(app, ui);
|
setting::sketch_halo(app, ui);
|
||||||
ui_kit::row(ui, app.tr("prefs.section_hatch"), |ui| {
|
ui_kit::row(ui, app.tr("prefs.section_hatch"), |ui| {
|
||||||
@@ -336,17 +336,17 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
app.preferences.section_hatch = *style;
|
app.preferences.section_hatch = *style;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let tint_new = setting::preview_tint_new_faces(app, ui).on_hover_text(app.tr("prefs.preview_tint_new_faces_help"));
|
let tint_new = setting::preview_tint_new_faces(app, ui).backing_on_hover_text(app.tr("prefs.preview_tint_new_faces_help"));
|
||||||
crate::scenario::publish_target("prefs:preview_tint_new_faces", tint_new.rect);
|
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"));
|
let cut_tint = setting::cut_preview_tint(app, ui).backing_on_hover_text(app.tr("prefs.cut_preview_tint_help"));
|
||||||
crate::scenario::publish_target("prefs:cut_preview_tint", cut_tint.rect);
|
crate::scenario::publish_target("prefs:cut_preview_tint", cut_tint.rect);
|
||||||
let reference =
|
let reference =
|
||||||
setting::reference_geometry_through_bodies(app, ui).on_hover_text(app.tr("settings.reference_geometry_through_bodies_help"));
|
setting::reference_geometry_through_bodies(app, ui).backing_on_hover_text(app.tr("settings.reference_geometry_through_bodies_help"));
|
||||||
crate::scenario::publish_target("dialog:Show reference geometry through bodies", reference.rect);
|
crate::scenario::publish_target("dialog:Show reference geometry through bodies", reference.rect);
|
||||||
ui.add_enabled_ui(app.ui.show_grid, |ui| {
|
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"));
|
setting::grid_follows_view(app, ui).backing_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"));
|
let response = setting::single_surface_analysis(app, ui).backing_on_hover_text(app.tr("prefs.single_surface_analysis_help"));
|
||||||
crate::scenario::publish_target("prefs:single_surface_analysis", response.rect);
|
crate::scenario::publish_target("prefs:single_surface_analysis", response.rect);
|
||||||
crate::scenario::publish_target("dialog:Use a single surface analysis", response.rect);
|
crate::scenario::publish_target("dialog:Use a single surface analysis", response.rect);
|
||||||
if response.changed() {
|
if response.changed() {
|
||||||
@@ -364,7 +364,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
}
|
}
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
window_system_row(app, ui, help_label_width);
|
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"));
|
setting::render_previous_geometry_budget(app, ui).backing_on_hover_text(app.tr("prefs.render_previous_geometry_budget_help"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -433,16 +433,19 @@ fn computer_card(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui)) {
|
|||||||
add(ui);
|
add(ui);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
egui::Frame::NONE
|
ui_kit::Container::show_backing(
|
||||||
.fill(t.pref_well)
|
ui,
|
||||||
.stroke(egui::Stroke::new(1.0, t.dialog_border))
|
egui::Frame::NONE
|
||||||
.corner_radius(f32::from(t.radius_lg) + t.space_2)
|
.fill(t.pref_well)
|
||||||
.inner_margin(egui::Margin::same(t.space_14 as i8))
|
.stroke(egui::Stroke::new(1.0, t.dialog_border))
|
||||||
.show(ui, |ui| {
|
.corner_radius(f32::from(t.radius_lg) + t.space_2)
|
||||||
|
.inner_margin(egui::Margin::same(t.space_14 as i8)),
|
||||||
|
|ui| {
|
||||||
ui.set_width(ui.available_width());
|
ui.set_width(ui.available_width());
|
||||||
ui.spacing_mut().item_spacing.y = t.space_12;
|
ui.spacing_mut().item_spacing.y = t.space_12;
|
||||||
add(ui);
|
add(ui);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The device line: a tile with a monitor, the adapter's name, its backend and renderer, and a badge.
|
/// The device line: a tile with a monitor, the adapter's name, its backend and renderer, and a badge.
|
||||||
@@ -474,8 +477,8 @@ fn computer_header(app: &SolveApp, ui: &mut egui::Ui) {
|
|||||||
paint_monitor(ui.painter(), egui::Rect::from_center_size(rect.center(), rect.size() * 0.56), t.form_primary);
|
paint_monitor(ui.painter(), egui::Rect::from_center_size(rect.center(), rect.size() * 0.56), t.form_primary);
|
||||||
ui.vertical(|ui| {
|
ui.vertical(|ui| {
|
||||||
ui.spacing_mut().item_spacing.y = 1.0;
|
ui.spacing_mut().item_spacing.y = 1.0;
|
||||||
ui_kit::text(ui, name, ui_kit::Text::NameCell(true)).on_hover_text(adapter);
|
ui_kit::text(ui, name, ui_kit::Text::NameCell(true)).backing_on_hover_text(adapter);
|
||||||
ui.label(egui::RichText::new(line).monospace().size(t.font_small).color(t.form_dim)).on_hover_text(renderer_details);
|
ui.label(egui::RichText::new(line).monospace().size(t.font_small).color(t.form_dim)).backing_on_hover_text(renderer_details);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ pub(super) fn appearance(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
|||||||
ui_kit::Slider { step: Some(0.05), format: |v| format!("{:.0} %", v * 100.0), ..Default::default() },
|
ui_kit::Slider { step: Some(0.05), format: |v| format!("{:.0} %", v * 100.0), ..Default::default() },
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.on_hover_text(app.tr("prefs.scale_note"));
|
.backing_on_hover_text(app.tr("prefs.scale_note"));
|
||||||
crate::scenario::publish_target("prefs:scale", response.rect);
|
crate::scenario::publish_target("prefs:scale", response.rect);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -160,7 +160,7 @@ pub(super) fn appearance(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
|||||||
app.preferences.ui_font.clear();
|
app.preferences.ui_font.clear();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ui.scope(|ui| ui_kit::font_specimen(ui, app.tr("prefs.font_sample"))).response.on_hover_text(app.tr("prefs.font_fallback"));
|
ui.scope(|ui| ui_kit::font_specimen(ui, app.tr("prefs.font_sample"))).response.backing_on_hover_text(app.tr("prefs.font_fallback"));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ui_kit::settings_section(ui, "colour_vision", app.tr("ui.colour_vision"), |ui| {
|
ui_kit::settings_section(ui, "colour_vision", app.tr("ui.colour_vision"), |ui| {
|
||||||
@@ -422,7 +422,7 @@ pub(super) fn layout(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
let options = [app.tr("prefs.dialogs_edge"), app.tr("prefs.dialogs_left_column")];
|
let options = [app.tr("prefs.dialogs_edge"), app.tr("prefs.dialogs_left_column")];
|
||||||
let segments =
|
let segments =
|
||||||
ui.scope(|ui| ui_kit::segmented(ui, &mut selected, &options, ui_kit::Segments { intrinsic: true, ..Default::default() }));
|
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"));
|
segments.response.backing_on_hover_text(app.tr("prefs.dialogs_note"));
|
||||||
if segments.inner {
|
if segments.inner {
|
||||||
app.preferences.dialog_place = if selected == 1 { DialogPlace::LeftColumn } else { DialogPlace::Edge };
|
app.preferences.dialog_place = if selected == 1 { DialogPlace::LeftColumn } else { DialogPlace::Edge };
|
||||||
}
|
}
|
||||||
@@ -560,7 +560,7 @@ fn layout_check(ui: &mut egui::Ui, label: &str, help: &str, value: &mut bool, ke
|
|||||||
ui.spacing_mut().item_spacing.x = t.space_6;
|
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 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| {
|
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)
|
ui_kit::checkbox(ui, label, value).backing_on_hover_text(help).backing_on_disabled_hover_text(help)
|
||||||
});
|
});
|
||||||
crate::scenario::publish_target(&format!("prefs:{key}"), response.rect);
|
crate::scenario::publish_target(&format!("prefs:{key}"), response.rect);
|
||||||
ui_kit::help_icon(ui, help);
|
ui_kit::help_icon(ui, help);
|
||||||
|
|||||||
@@ -17,104 +17,103 @@ fn device(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
let t = Tokens::get();
|
let t = Tokens::get();
|
||||||
ui_kit::settings_section(ui, "space_mouse_device", app.tr("prefs.navigation.device"), |ui| {
|
ui_kit::settings_section(ui, "space_mouse_device", app.tr("prefs.navigation.device"), |ui| {
|
||||||
// A confirmed input record identifies the device; an open reader alone does not.
|
// A confirmed input record identifies the device; an open reader alone does not.
|
||||||
let card =
|
let card = ui_kit::settings_card(ui, |ui| {
|
||||||
ui_kit::settings_card(ui, |ui| {
|
let device = app.space_mouse.device.clone();
|
||||||
let device = app.space_mouse.device.clone();
|
let saved = device.as_ref().is_some_and(|device| app.preferences.space_mouse_calibrations.contains_key(device));
|
||||||
let saved = device.as_ref().is_some_and(|device| app.preferences.space_mouse_calibrations.contains_key(device));
|
let denied = found_but_not_allowed(app.space_mouse.drivers);
|
||||||
let denied = found_but_not_allowed(app.space_mouse.drivers);
|
// Native wrapping labels use the initial row height as their first-line minimum.
|
||||||
// Native wrapping labels use the initial row height as their first-line minimum.
|
ui.allocate_ui_with_layout(
|
||||||
ui.allocate_ui_with_layout(
|
egui::vec2(ui.available_width(), t.control_height),
|
||||||
egui::vec2(ui.available_width(), t.control_height),
|
egui::Layout::left_to_right(egui::Align::Min).with_main_wrap(true),
|
||||||
egui::Layout::left_to_right(egui::Align::Min).with_main_wrap(true),
|
|ui| {
|
||||||
|ui| {
|
if let Some(device) = &device {
|
||||||
if let Some(device) = &device {
|
ui_kit::text(ui, device, ui_kit::Text::Inline);
|
||||||
ui_kit::text(ui, device, ui_kit::Text::Inline);
|
ui_kit::settings_state_badge(ui, app.tr("ui.nav_bridge_connected"), ui_kit::SettingsBadgeTone::Success);
|
||||||
ui_kit::settings_state_badge(ui, app.tr("ui.nav_bridge_connected"), ui_kit::SettingsBadgeTone::Success);
|
ui_kit::settings_state_badge(
|
||||||
ui_kit::settings_state_badge(
|
ui,
|
||||||
ui,
|
app.tr(if saved { "ui.3d_mouse_calibrated" } else { "ui.3d_mouse_not_calibrated" }),
|
||||||
app.tr(if saved { "ui.3d_mouse_calibrated" } else { "ui.3d_mouse_not_calibrated" }),
|
if saved { ui_kit::SettingsBadgeTone::Success } else { ui_kit::SettingsBadgeTone::Warning },
|
||||||
if saved { ui_kit::SettingsBadgeTone::Success } else { ui_kit::SettingsBadgeTone::Warning },
|
);
|
||||||
);
|
} else {
|
||||||
|
let status = app.tr(if denied {
|
||||||
|
"ui.3d_mouse_denied_short"
|
||||||
|
} else if waiting_for_a_record(app.space_mouse.drivers) {
|
||||||
|
"ui.3d_mouse_calibrate_wait"
|
||||||
} else {
|
} else {
|
||||||
let status = app.tr(if denied {
|
"ui.3d_mouse_none"
|
||||||
"ui.3d_mouse_denied_short"
|
});
|
||||||
} else if waiting_for_a_record(app.space_mouse.drivers) {
|
let response = ui_kit::text(ui, status, ui_kit::Text::Report("calibration_status", false))
|
||||||
"ui.3d_mouse_calibrate_wait"
|
.backing_on_hover_text(app.tr(if denied { "ui.3d_mouse_denied" } else { "ui.3d_mouse_wiggle" }));
|
||||||
} else {
|
crate::scenario::publish_target("prefs:space_mouse_status", response.rect);
|
||||||
"ui.3d_mouse_none"
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let calibrate_width = ui_kit::ContentWidth::button(ui, app.tr("ui.3d_mouse_calibrate"));
|
||||||
|
let forget_width = ui_kit::ContentWidth::button(ui, app.tr("ui.3d_mouse_forget"));
|
||||||
|
let available = ui.available_width();
|
||||||
|
ui.allocate_ui_with_layout(
|
||||||
|
egui::vec2(available, t.control_height),
|
||||||
|
egui::Layout::left_to_right(egui::Align::Min).with_main_wrap(true),
|
||||||
|
|ui| {
|
||||||
|
ui.spacing_mut().item_spacing.x = t.space_8;
|
||||||
|
ui.allocate_ui_with_layout(
|
||||||
|
egui::vec2(calibrate_width.min(available), t.control_height),
|
||||||
|
egui::Layout::top_down(egui::Align::Min),
|
||||||
|
|ui| {
|
||||||
|
ui_kit::settings_form(ui, 0.0, |ui| {
|
||||||
|
ui_kit::row(ui, app.tr("ui.3d_mouse_calibrate"), |ui| {
|
||||||
|
ui.add_enabled_ui(app.preferences.space_mouse_enabled && device.is_some(), |ui| {
|
||||||
|
let response = ui_kit::button(ui, app.tr("ui.3d_mouse_calibrate"), ui_kit::Button::Normal)
|
||||||
|
.backing_on_hover_text(app.tr("ui.3d_mouse_calibrate_tip"))
|
||||||
|
.backing_on_disabled_hover_text(app.tr(if device.is_none() {
|
||||||
|
"ui.3d_mouse_calibrate_wait"
|
||||||
|
} else {
|
||||||
|
"ui.3d_mouse_calibrate_tip"
|
||||||
|
}));
|
||||||
|
crate::scenario::publish_target("prefs:space_mouse_calibrate", response.rect);
|
||||||
|
crate::scenario::publish_target(
|
||||||
|
&format!("dialog:button:Calibration:{}", app.tr("ui.3d_mouse_calibrate")),
|
||||||
|
response.rect,
|
||||||
|
);
|
||||||
|
if response.clicked() {
|
||||||
|
crate::space_mouse_calibration::start(app);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
let response = ui_kit::text(ui, status, ui_kit::Text::Report("calibration_status", false))
|
},
|
||||||
.on_hover_text(app.tr(if denied { "ui.3d_mouse_denied" } else { "ui.3d_mouse_wiggle" }));
|
);
|
||||||
crate::scenario::publish_target("prefs:space_mouse_status", response.rect);
|
if saved {
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let calibrate_width = ui_kit::ContentWidth::button(ui, app.tr("ui.3d_mouse_calibrate"));
|
|
||||||
let forget_width = ui_kit::ContentWidth::button(ui, app.tr("ui.3d_mouse_forget"));
|
|
||||||
let available = ui.available_width();
|
|
||||||
ui.allocate_ui_with_layout(
|
|
||||||
egui::vec2(available, t.control_height),
|
|
||||||
egui::Layout::left_to_right(egui::Align::Min).with_main_wrap(true),
|
|
||||||
|ui| {
|
|
||||||
ui.spacing_mut().item_spacing.x = t.space_8;
|
|
||||||
ui.allocate_ui_with_layout(
|
ui.allocate_ui_with_layout(
|
||||||
egui::vec2(calibrate_width.min(available), t.control_height),
|
egui::vec2(forget_width.min(available), t.control_height),
|
||||||
egui::Layout::top_down(egui::Align::Min),
|
egui::Layout::top_down(egui::Align::Min),
|
||||||
|ui| {
|
|ui| {
|
||||||
ui_kit::settings_form(ui, 0.0, |ui| {
|
ui_kit::settings_form(ui, 0.0, |ui| {
|
||||||
ui_kit::row(ui, app.tr("ui.3d_mouse_calibrate"), |ui| {
|
ui_kit::row(ui, app.tr("ui.3d_mouse_forget"), |ui| {
|
||||||
ui.add_enabled_ui(app.preferences.space_mouse_enabled && device.is_some(), |ui| {
|
ui.add_enabled_ui(app.preferences.space_mouse_enabled, |ui| {
|
||||||
let response = ui_kit::button(ui, app.tr("ui.3d_mouse_calibrate"), ui_kit::Button::Normal)
|
let response = ui_kit::button(ui, app.tr("ui.3d_mouse_forget"), ui_kit::Button::Normal);
|
||||||
.on_hover_text(app.tr("ui.3d_mouse_calibrate_tip"))
|
crate::scenario::publish_target("prefs:space_mouse_forget", response.rect);
|
||||||
.on_disabled_hover_text(app.tr(if device.is_none() {
|
|
||||||
"ui.3d_mouse_calibrate_wait"
|
|
||||||
} else {
|
|
||||||
"ui.3d_mouse_calibrate_tip"
|
|
||||||
}));
|
|
||||||
crate::scenario::publish_target("prefs:space_mouse_calibrate", response.rect);
|
|
||||||
crate::scenario::publish_target(
|
crate::scenario::publish_target(
|
||||||
&format!("dialog:button:Calibration:{}", app.tr("ui.3d_mouse_calibrate")),
|
&format!("dialog:button:Calibration:{}", app.tr("ui.3d_mouse_forget")),
|
||||||
response.rect,
|
response.rect,
|
||||||
);
|
);
|
||||||
if response.clicked() {
|
if response.clicked()
|
||||||
crate::space_mouse_calibration::start(app);
|
&& let Some(device) = &device
|
||||||
|
{
|
||||||
|
app.preferences.space_mouse_calibrations.remove(device);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if saved {
|
}
|
||||||
ui.allocate_ui_with_layout(
|
},
|
||||||
egui::vec2(forget_width.min(available), t.control_height),
|
);
|
||||||
egui::Layout::top_down(egui::Align::Min),
|
});
|
||||||
|ui| {
|
|
||||||
ui_kit::settings_form(ui, 0.0, |ui| {
|
|
||||||
ui_kit::row(ui, app.tr("ui.3d_mouse_forget"), |ui| {
|
|
||||||
ui.add_enabled_ui(app.preferences.space_mouse_enabled, |ui| {
|
|
||||||
let response = ui_kit::button(ui, app.tr("ui.3d_mouse_forget"), ui_kit::Button::Normal);
|
|
||||||
crate::scenario::publish_target("prefs:space_mouse_forget", response.rect);
|
|
||||||
crate::scenario::publish_target(
|
|
||||||
&format!("dialog:button:Calibration:{}", app.tr("ui.3d_mouse_forget")),
|
|
||||||
response.rect,
|
|
||||||
);
|
|
||||||
if response.clicked()
|
|
||||||
&& let Some(device) = &device
|
|
||||||
{
|
|
||||||
app.preferences.space_mouse_calibrations.remove(device);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
crate::scenario::publish_rect("prefs:space_mouse_device_card", card.response.rect);
|
crate::scenario::publish_rect("prefs:space_mouse_device_card", card.response.rect);
|
||||||
setting::space_mouse_enabled(app, ui).on_hover_text(crate::i18n::tr("ui.3d_mouse_hint"));
|
setting::space_mouse_enabled(app, ui).backing_on_hover_text(crate::i18n::tr("ui.3d_mouse_hint"));
|
||||||
setting::space_mouse_in_view(app, ui).on_hover_text(crate::i18n::tr("ui.3d_mouse_show_in_view_tip"));
|
setting::space_mouse_in_view(app, ui).backing_on_hover_text(crate::i18n::tr("ui.3d_mouse_show_in_view_tip"));
|
||||||
let names = [
|
let names = [
|
||||||
(SpaceMouseGhost::LeftViewport, app.tr("ui.3d_mouse_ghost_left_viewport").to_owned()),
|
(SpaceMouseGhost::LeftViewport, app.tr("ui.3d_mouse_ghost_left_viewport").to_owned()),
|
||||||
(SpaceMouseGhost::RightViewport, app.tr("ui.3d_mouse_ghost_right_viewport").to_owned()),
|
(SpaceMouseGhost::RightViewport, app.tr("ui.3d_mouse_ghost_right_viewport").to_owned()),
|
||||||
@@ -192,7 +191,7 @@ fn orbit(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
ui.add_enabled_ui(app.preferences.space_mouse_enabled, |ui| {
|
ui.add_enabled_ui(app.preferences.space_mouse_enabled, |ui| {
|
||||||
setting::space_mouse_sketch_orbit(app, ui).on_hover_text(crate::i18n::tr("ui.3d_mouse_sketch_orbit_tip"));
|
setting::space_mouse_sketch_orbit(app, ui).backing_on_hover_text(crate::i18n::tr("ui.3d_mouse_sketch_orbit_tip"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -264,7 +263,7 @@ fn axes(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on_hover_text(if *off { &off_tip } else { &driven_by });
|
.backing_on_hover_text(if *off { &off_tip } else { &driven_by });
|
||||||
if chosen != current {
|
if chosen != current {
|
||||||
crate::space_mouse::reassign(source, axis, chosen);
|
crate::space_mouse::reassign(source, axis, chosen);
|
||||||
}
|
}
|
||||||
@@ -342,7 +341,7 @@ fn advanced(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
0.0..=solidcraft_space_mouse::MAX_DEAD_ZONE_SHARE,
|
0.0..=solidcraft_space_mouse::MAX_DEAD_ZONE_SHARE,
|
||||||
ui_kit::Slider { step: Some(0.01), format: |v| format!("{:.0} %", v * 100.0), ..Default::default() },
|
ui_kit::Slider { step: Some(0.01), format: |v| format!("{:.0} %", v * 100.0), ..Default::default() },
|
||||||
)
|
)
|
||||||
.on_hover_text(tip);
|
.backing_on_hover_text(tip);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
use crate::navigation::NavCaps;
|
use crate::navigation::NavCaps;
|
||||||
use crate::navigation_bar::{self, Edge, Idle, Kind, Labels, MenuDisplay, NavigationBar, Position, Size, SlotOptions};
|
use crate::navigation_bar::{self, Edge, Idle, Kind, Labels, MenuDisplay, NavigationBar, Position, Size, SlotOptions};
|
||||||
use crate::navigation_bar_view;
|
use crate::navigation_bar_view;
|
||||||
|
use crate::ui_kit::BackingTooltipExt as _;
|
||||||
use crate::{SolveApp, scenario, theme::Tokens, ui_kit};
|
use crate::{SolveApp, scenario, theme::Tokens, ui_kit};
|
||||||
use egui::{Rect, Sense, vec2};
|
use egui::{Rect, Sense, vec2};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
@@ -363,7 +364,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_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));
|
ui.interact(name, ui.make_persistent_id("slot_help"), Sense::hover()).backing_on_hover_text(app.tr(slot.description));
|
||||||
scenario::publish_rect("prefs:navbar:inspector:name", name);
|
scenario::publish_rect("prefs:navbar:inspector:name", name);
|
||||||
scenario::publish_rect("prefs:navbar:inspector:header", bounds);
|
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));
|
let mut actions_ui = ui.new_child(egui::UiBuilder::new().id_salt("slot_actions").max_rect(bounds));
|
||||||
@@ -391,7 +392,7 @@ fn inspector_header(app: &mut SolveApp, ui: &mut egui::Ui, state: &mut State, in
|
|||||||
.inner
|
.inner
|
||||||
})
|
})
|
||||||
.inner
|
.inner
|
||||||
.on_hover_text(label);
|
.backing_on_hover_text(label);
|
||||||
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled && ui.is_enabled(), label));
|
response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled && ui.is_enabled(), label));
|
||||||
scenario::publish_target(&format!("prefs:navbar:{key}"), response.rect);
|
scenario::publish_target(&format!("prefs:navbar:{key}"), response.rect);
|
||||||
if response.clicked() {
|
if response.clicked() {
|
||||||
@@ -414,7 +415,7 @@ fn available(app: &mut SolveApp, ui: &mut egui::Ui, state: &mut State) {
|
|||||||
let dragging =
|
let dragging =
|
||||||
egui::DragAndDrop::payload::<Drag>(ui.ctx()).is_some_and(|payload| payload.from_menu && payload.id.slot == slot.id);
|
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, add) = ui_kit::navigation_separator_source(ui, slot.id, app.tr(slot.label), dragging);
|
||||||
let tile = tile.on_hover_text(hint);
|
let tile = tile.backing_on_hover_text(hint);
|
||||||
scenario::publish_target(&format!("prefs:navbar:tile:{}", slot.id), tile.rect);
|
scenario::publish_target(&format!("prefs:navbar:tile:{}", slot.id), tile.rect);
|
||||||
scenario::publish_target(&format!("prefs:navbar:add:{}", slot.id), add.rect);
|
scenario::publish_target(&format!("prefs:navbar:add:{}", slot.id), add.rect);
|
||||||
if tile.drag_started() {
|
if tile.drag_started() {
|
||||||
@@ -441,7 +442,7 @@ fn available(app: &mut SolveApp, ui: &mut egui::Ui, state: &mut State) {
|
|||||||
}
|
}
|
||||||
let dragging = egui::DragAndDrop::payload::<Drag>(ui.ctx()).is_some_and(|payload| payload.from_menu && payload.id.slot == slot.id);
|
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, 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);
|
let tile = tile.backing_on_hover_text(&caption);
|
||||||
scenario::publish_target(&format!("prefs:navbar:tile:{}", slot.id), tile.rect);
|
scenario::publish_target(&format!("prefs:navbar:tile:{}", slot.id), tile.rect);
|
||||||
scenario::publish_target(&format!("prefs:navbar:add:{}", slot.id), add.rect);
|
scenario::publish_target(&format!("prefs:navbar:add:{}", slot.id), add.rect);
|
||||||
if tile.drag_started() {
|
if tile.drag_started() {
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ fn mouse(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
|||||||
let response = ui_kit::dropdown_menu(ui, "pref_nav", nav_preset(&app.preferences.nav).translated_label(), |ui| {
|
let response = ui_kit::dropdown_menu(ui, "pref_nav", nav_preset(&app.preferences.nav).translated_label(), |ui| {
|
||||||
for preset in NAV_PRESETS {
|
for preset in NAV_PRESETS {
|
||||||
if ui_kit::list_picker(ui, preset.id, preset.translated_label(), app.preferences.nav == preset.id)
|
if ui_kit::list_picker(ui, preset.id, preset.translated_label(), app.preferences.nav == preset.id)
|
||||||
.on_hover_text(preset.translated_help())
|
.backing_on_hover_text(preset.translated_help())
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
choose_nav_preset(&mut app.preferences, preset);
|
choose_nav_preset(&mut app.preferences, preset);
|
||||||
@@ -101,16 +101,16 @@ fn mouse(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
|||||||
});
|
});
|
||||||
crate::scenario::publish_target("dialog:Pan, zoom, orbit", response.rect);
|
crate::scenario::publish_target("dialog:Pan, zoom, orbit", response.rect);
|
||||||
crate::scenario::publish_target("prefs:nav", response.rect);
|
crate::scenario::publish_target("prefs:nav", response.rect);
|
||||||
response.on_hover_text(nav_preset(&app.preferences.nav).translated_help());
|
response.backing_on_hover_text(nav_preset(&app.preferences.nav).translated_help());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
if nav_preset(&app.preferences.nav).bindings.iter().any(|b| b.button == NavButton::Right && b.mode == NavMode::Orbit) {
|
if nav_preset(&app.preferences.nav).bindings.iter().any(|b| b.button == NavButton::Right && b.mode == NavMode::Orbit) {
|
||||||
let response = setting::right_drag_orbits(app, ui).on_hover_text(crate::i18n::tr("nav.right_drag_orbits.help"));
|
let response = setting::right_drag_orbits(app, ui).backing_on_hover_text(crate::i18n::tr("nav.right_drag_orbits.help"));
|
||||||
crate::scenario::publish_target("prefs:right_drag_orbits", response.rect);
|
crate::scenario::publish_target("prefs:right_drag_orbits", response.rect);
|
||||||
}
|
}
|
||||||
setting::radial_context_menu(app, ui).on_hover_text(crate::i18n::tr("prefs.navigation.radial_help"));
|
setting::radial_context_menu(app, ui).backing_on_hover_text(crate::i18n::tr("prefs.navigation.radial_help"));
|
||||||
let label = app.tr("Middle double-click");
|
let label = app.tr("Middle double-click");
|
||||||
let width = ui_kit::ContentWidth::label(ui, label);
|
let width = ui_kit::ContentWidth::label(ui, label);
|
||||||
ui_kit::settings_form(ui, width, |ui| {
|
ui_kit::settings_form(ui, width, |ui| {
|
||||||
@@ -125,7 +125,7 @@ fn mouse(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
crate::scenario::publish_target("prefs:middle_double_click", response.rect);
|
crate::scenario::publish_target("prefs:middle_double_click", response.rect);
|
||||||
response.on_hover_text(crate::i18n::tr_label("Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing"));
|
response.backing_on_hover_text(crate::i18n::tr_label("Double-clicking the middle button in the view: Home (the home view, framing what is shown), Fit (frame what is shown, keeping the orientation) or nothing"));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -197,7 +197,7 @@ fn orbit(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
ui.add_enabled_ui(app.preferences.free_orbit, |ui| {
|
ui.add_enabled_ui(app.preferences.free_orbit, |ui| {
|
||||||
let response = setting::free_orbit_upright(app, ui).on_hover_text(crate::i18n::tr("nav.free_orbit_upright.help"));
|
let response = setting::free_orbit_upright(app, ui).backing_on_hover_text(crate::i18n::tr("nav.free_orbit_upright.help"));
|
||||||
crate::scenario::publish_target("prefs:free_orbit_upright", response.rect);
|
crate::scenario::publish_target("prefs:free_orbit_upright", response.rect);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -227,12 +227,14 @@ fn wheel(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
|||||||
setting::smooth_wheel_zoom(app, ui);
|
setting::smooth_wheel_zoom(app, ui);
|
||||||
let touchpad = app.preferences.nav == TOUCHPAD_NAV;
|
let touchpad = app.preferences.nav == TOUCHPAD_NAV;
|
||||||
ui.add_enabled_ui(!touchpad, |ui| {
|
ui.add_enabled_ui(!touchpad, |ui| {
|
||||||
let response = ui_kit::checkbox(ui, crate::i18n::tr("ui.two_finger_scroll_pans"), &mut app.preferences.trackpad_pan)
|
let response =
|
||||||
.on_hover_text(if touchpad { crate::i18n::tr("nav.touchpad.scroll") } else { crate::i18n::tr("ui.trackpad_pan_tip") });
|
ui_kit::checkbox(ui, crate::i18n::tr("ui.two_finger_scroll_pans"), &mut app.preferences.trackpad_pan).backing_on_hover_text(
|
||||||
|
if touchpad { crate::i18n::tr("nav.touchpad.scroll") } else { crate::i18n::tr("ui.trackpad_pan_tip") },
|
||||||
|
);
|
||||||
crate::scenario::publish_target("prefs:trackpad_pan", response.rect);
|
crate::scenario::publish_target("prefs:trackpad_pan", response.rect);
|
||||||
});
|
});
|
||||||
setting::orbit_reverse(app, ui);
|
setting::orbit_reverse(app, ui);
|
||||||
let response = setting::zoom_stop_at_model(app, ui).on_hover_text(crate::i18n::tr("prefs.zoom_stop_at_model_tip"));
|
let response = setting::zoom_stop_at_model(app, ui).backing_on_hover_text(crate::i18n::tr("prefs.zoom_stop_at_model_tip"));
|
||||||
crate::scenario::publish_target("prefs:zoom_stop_at_model", response.rect);
|
crate::scenario::publish_target("prefs:zoom_stop_at_model", response.rect);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -260,7 +262,7 @@ fn wheel(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on_hover_text(crate::i18n::tr("prefs.trackpad_zoom_modifier.help"));
|
.backing_on_hover_text(crate::i18n::tr("prefs.trackpad_zoom_modifier.help"));
|
||||||
crate::scenario::publish_target("prefs:trackpad_zoom_modifier", response.rect);
|
crate::scenario::publish_target("prefs:trackpad_zoom_modifier", response.rect);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -302,17 +304,17 @@ fn view(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) {
|
|||||||
ui_kit::Slider { step: Some(0.05), format: |v| format!("{:.0} %", v * 100.0), ..Default::default() },
|
ui_kit::Slider { step: Some(0.05), format: |v| format!("{:.0} %", v * 100.0), ..Default::default() },
|
||||||
);
|
);
|
||||||
crate::scenario::publish_rect("prefs:home_fit_size", response.rect);
|
crate::scenario::publish_rect("prefs:home_fit_size", response.rect);
|
||||||
response.on_hover_text(note);
|
response.backing_on_hover_text(note);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
let (label, note) = (app.tr("prefs.navigator_fit").to_owned(), app.tr("prefs.navigator_fit_note").to_owned());
|
let (label, note) = (app.tr("prefs.navigator_fit").to_owned(), app.tr("prefs.navigator_fit_note").to_owned());
|
||||||
let response = ui_kit::checkbox(ui, &label, &mut app.preferences.navigator_fit).on_hover_text(note);
|
let response = ui_kit::checkbox(ui, &label, &mut app.preferences.navigator_fit).backing_on_hover_text(note);
|
||||||
crate::scenario::publish_target("prefs:navigator_fit", response.rect);
|
crate::scenario::publish_target("prefs:navigator_fit", response.rect);
|
||||||
let (label, note) = (app.tr("prefs.invert_navigator_arrows").to_owned(), app.tr("prefs.invert_navigator_arrows_note").to_owned());
|
let (label, note) = (app.tr("prefs.invert_navigator_arrows").to_owned(), app.tr("prefs.invert_navigator_arrows_note").to_owned());
|
||||||
let response = ui_kit::checkbox(ui, &label, &mut app.preferences.invert_navigator_arrows).on_hover_text(note);
|
let response = ui_kit::checkbox(ui, &label, &mut app.preferences.invert_navigator_arrows).backing_on_hover_text(note);
|
||||||
crate::scenario::publish_target("prefs:invert_navigator_arrows", response.rect);
|
crate::scenario::publish_target("prefs:invert_navigator_arrows", response.rect);
|
||||||
let (label, note) = (app.tr("prefs.invert_curved_roll_arrows").to_owned(), app.tr("prefs.invert_curved_roll_arrows_note").to_owned());
|
let (label, note) = (app.tr("prefs.invert_curved_roll_arrows").to_owned(), app.tr("prefs.invert_curved_roll_arrows_note").to_owned());
|
||||||
let response = ui_kit::checkbox(ui, &label, &mut app.preferences.invert_curved_roll_arrows).on_hover_text(note);
|
let response = ui_kit::checkbox(ui, &label, &mut app.preferences.invert_curved_roll_arrows).backing_on_hover_text(note);
|
||||||
crate::scenario::publish_target("prefs:invert_curved_roll_arrows", response.rect);
|
crate::scenario::publish_target("prefs:invert_curved_roll_arrows", response.rect);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ fn snapping(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on_hover_text(crate::i18n::tr("prefs.snap_drawing_note"));
|
.backing_on_hover_text(crate::i18n::tr("prefs.snap_drawing_note"));
|
||||||
crate::scenario::publish_target("prefs:suppress_sketch_snap", response.rect);
|
crate::scenario::publish_target("prefs:suppress_sketch_snap", response.rect);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -232,7 +232,7 @@ fn opening(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
},
|
},
|
||||||
width,
|
width,
|
||||||
)
|
)
|
||||||
.on_hover_text(app.tr("prefs.sketch_exit_view_note"));
|
.backing_on_hover_text(app.tr("prefs.sketch_exit_view_note"));
|
||||||
crate::scenario::publish_target(&format!("prefs:sketch_exit_view:{key}"), response.rect);
|
crate::scenario::publish_target(&format!("prefs:sketch_exit_view:{key}"), response.rect);
|
||||||
crate::scenario::publish_target(&format!("dialog:list:{}", choice.label_id()), response.rect);
|
crate::scenario::publish_target(&format!("dialog:list:{}", choice.label_id()), response.rect);
|
||||||
if app.preferences.sketch_exit_view == choice {
|
if app.preferences.sketch_exit_view == choice {
|
||||||
@@ -307,7 +307,7 @@ fn marks(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
},
|
},
|
||||||
width,
|
width,
|
||||||
)
|
)
|
||||||
.on_hover_text(crate::i18n::tr(help));
|
.backing_on_hover_text(crate::i18n::tr(help));
|
||||||
targets(key, &response);
|
targets(key, &response);
|
||||||
if response.clicked() {
|
if response.clicked() {
|
||||||
*value = !*value;
|
*value = !*value;
|
||||||
@@ -451,7 +451,7 @@ fn check(ui: &mut egui::Ui, key: &str, label: &str, help: Option<&str>, value: &
|
|||||||
if show_help && response.rect.is_positive() {
|
if show_help && response.rect.is_positive() {
|
||||||
ui_kit::help_icon(ui, help);
|
ui_kit::help_icon(ui, help);
|
||||||
}
|
}
|
||||||
response.on_hover_text(help)
|
response.backing_on_hover_text(help)
|
||||||
} else {
|
} else {
|
||||||
response
|
response
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
(setting::hole_moves, "prefs.design_tools.hole_moves_help", "hole_moves", "Move holes by clicking or dragging"),
|
(setting::hole_moves, "prefs.design_tools.hole_moves_help", "hole_moves", "Move holes by clicking or dragging"),
|
||||||
] {
|
] {
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
let response = setting(app, ui).on_hover_text(app.tr(help));
|
let response = setting(app, ui).backing_on_hover_text(app.tr(help));
|
||||||
crate::scenario::publish_target(&format!("prefs:{key}"), response.rect);
|
crate::scenario::publish_target(&format!("prefs:{key}"), response.rect);
|
||||||
crate::scenario::publish_target(&format!("dialog:{legacy}"), response.rect);
|
crate::scenario::publish_target(&format!("dialog:{legacy}"), response.rect);
|
||||||
if !crate::prefs_search::suppress_chrome(ui) {
|
if !crate::prefs_search::suppress_chrome(ui) {
|
||||||
@@ -37,7 +37,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
(setting::auto_operation, "prefs.auto_operation_note", "auto_operation"),
|
(setting::auto_operation, "prefs.auto_operation_note", "auto_operation"),
|
||||||
] {
|
] {
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
let response = setting(app, ui).on_hover_text(app.tr(help));
|
let response = setting(app, ui).backing_on_hover_text(app.tr(help));
|
||||||
crate::scenario::publish_target(&format!("prefs:{key}"), response.rect);
|
crate::scenario::publish_target(&format!("prefs:{key}"), response.rect);
|
||||||
if !crate::prefs_search::suppress_chrome(ui) {
|
if !crate::prefs_search::suppress_chrome(ui) {
|
||||||
ui_kit::help_icon(ui, app.tr(help));
|
ui_kit::help_icon(ui, app.tr(help));
|
||||||
@@ -55,7 +55,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
});
|
});
|
||||||
create_row(ui, app.tr("prefs.design_tools.placement_group"), &["rest", "boss", "snap_fit"], false, |ui| {
|
create_row(ui, app.tr("prefs.design_tools.placement_group"), &["rest", "boss", "snap_fit"], false, |ui| {
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
let response = setting::placement_handles(app, ui).on_hover_text(app.tr("ui.placement_handles_tip"));
|
let response = setting::placement_handles(app, ui).backing_on_hover_text(app.tr("ui.placement_handles_tip"));
|
||||||
crate::scenario::publish_target("prefs:placement_handles", response.rect);
|
crate::scenario::publish_target("prefs:placement_handles", response.rect);
|
||||||
if !crate::prefs_search::suppress_chrome(ui) {
|
if !crate::prefs_search::suppress_chrome(ui) {
|
||||||
ui_kit::help_icon(ui, app.tr("ui.placement_handles_tip"));
|
ui_kit::help_icon(ui, app.tr("ui.placement_handles_tip"));
|
||||||
@@ -67,7 +67,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
ui_kit::settings_section(ui, "joint_move", app.tr("joint_move.settings"), |ui| {
|
ui_kit::settings_section(ui, "joint_move", app.tr("joint_move.settings"), |ui| {
|
||||||
tool_group(ui, "", ui_kit::paint_assembly_move, |ui| {
|
tool_group(ui, "", ui_kit::paint_assembly_move, |ui| {
|
||||||
ui.horizontal_wrapped(|ui| {
|
ui.horizontal_wrapped(|ui| {
|
||||||
let response = setting::assembly_move_handles(app, ui).on_hover_text(app.tr("joint_move.legacy_hint"));
|
let response = setting::assembly_move_handles(app, ui).backing_on_hover_text(app.tr("joint_move.legacy_hint"));
|
||||||
crate::scenario::publish_target("prefs:assembly_move_handles", response.rect);
|
crate::scenario::publish_target("prefs:assembly_move_handles", response.rect);
|
||||||
crate::scenario::publish_target("dialog:Use joint motion handles for assembly Move", response.rect);
|
crate::scenario::publish_target("dialog:Use joint motion handles for assembly Move", response.rect);
|
||||||
if !crate::prefs_search::suppress_chrome(ui) {
|
if !crate::prefs_search::suppress_chrome(ui) {
|
||||||
@@ -137,7 +137,7 @@ pub(super) fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if key == "direct_interaction" {
|
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"));
|
response.backing_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"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -187,8 +187,9 @@ fn create_card(ui: &mut egui::Ui, rows: impl FnOnce(&mut egui::Ui)) {
|
|||||||
rows(ui);
|
rows(ui);
|
||||||
return;
|
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_kit::Container::show_backing(
|
||||||
ui,
|
ui,
|
||||||
|
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),
|
||||||
|ui| {
|
|ui| {
|
||||||
ui.spacing_mut().item_spacing.y = 0.0;
|
ui.spacing_mut().item_spacing.y = 0.0;
|
||||||
ui.set_width(ui.available_width());
|
ui.set_width(ui.available_width());
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user