mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-04 11:05:56 +00:00
Fillet dialog: one option model, plain labels, refusals in plain words
The dialog follows the options solid.fillet takes together: If too large (fit) and Corners show only for Constant radius, a curvature corner and a fitted radius rule each other out and the other resets visibly, and an option that does not apply is never sent (it used to send `corner: g2` with Reduce or Taper and get refused). Variable Radius shows only for the types that take it; the Advanced section held only the fit and is gone. Each option has a one-line hover text. Kernel refusals read in plain words, with the raw text in the hover, Copy Details and the issue report. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"en": {
|
||||
"ui.copy_details": "Copy Details",
|
||||
"ui.copy_details_tip": "Copy the full message, to look it up or to report it.",
|
||||
"ui.fillet_error_corner": "The smooth corner could not be built here. Try another Setback, or Tangent corners.",
|
||||
"ui.fillet_error_failed": "This fillet could not be built on these edges. Try another radius, or round fewer edges at once.",
|
||||
"ui.fillet_error_too_large": "The radius is too large for the faces beside these edges. Try a smaller radius, or set If too large to Largest that fits.",
|
||||
"ui.fillet_fit": "If too large",
|
||||
"ui.fillet_fit_exact": "Stop and say so",
|
||||
"ui.fillet_fit_reduce": "Largest that fits",
|
||||
"ui.fillet_fit_taper": "Narrow where needed",
|
||||
"ui.fillet_fit_tip": "When the radius is larger than the faces beside an edge allow: stop with a message, round every edge with the largest radius that fits, or keep the radius and narrow the round only where the faces run out.",
|
||||
"ui.fillet_options_tip": "Tangent chain: also round the edges that continue smoothly from the picked ones.\nPreview: show the result in the view while you edit.",
|
||||
"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."
|
||||
},
|
||||
"ar": {
|
||||
"ui.copy_details": "نسخ التفاصيل",
|
||||
"ui.copy_details_tip": "انسخ الرسالة كاملة للبحث عنها أو للإبلاغ عنها.",
|
||||
"ui.fillet_error_corner": "تعذّر بناء الزاوية الناعمة هنا. جرّب مسافة إرجاع أخرى، أو زوايا مماسّة.",
|
||||
"ui.fillet_error_failed": "تعذّر بناء هذا التقويس على هذه الحواف. جرّب نصف قطر آخر، أو قوّس حوافّ أقل في المرة الواحدة.",
|
||||
"ui.fillet_error_too_large": "نصف القطر كبير جدًا على الوجوه المجاورة لهذه الحواف. جرّب نصف قطر أصغر، أو اختر في «إذا كان كبيرًا جدًا» الخيار «استخدم أكبر ما يتّسع».",
|
||||
"ui.fillet_fit": "إذا كان كبيرًا جدًا",
|
||||
"ui.fillet_fit_exact": "توقّف وأخبرني",
|
||||
"ui.fillet_fit_reduce": "استخدم أكبر ما يتّسع",
|
||||
"ui.fillet_fit_taper": "ضيّقه حيث يلزم",
|
||||
"ui.fillet_fit_tip": "عندما يكون نصف القطر أكبر مما تسمح به الوجوه المجاورة للحافة: توقّف برسالة، أو قوّس كل حافة بأكبر نصف قطر يتّسع، أو أبقِ نصف القطر وضيّق التقويس فقط حيث تنتهي الوجوه.",
|
||||
"ui.fillet_options_tip": "سلسلة مماسّة: قوّس أيضًا الحواف التي تتصل بسلاسة بالحواف المختارة.\nمعاينة: اعرض النتيجة في العرض أثناء التحرير.",
|
||||
"ui.fillet_type_choices_tip": "نصف قطر ثابت: نصف قطر واحد على طول الحافة.\nطول الوتر: يبقى عرض التقويس نفسه مهما كانت الزاوية بين الوجوه.\nنصف قطر متغيّر: نصف قطر عند كل طرف، ومزيد على طول الحافة إن أضفته.\nمتصل الانحناء: مقطع بلا انكسار في الانعكاسات حيث يلتقي بالوجوه المستوية (G2).\nمنحنى: يتبع نصف القطر على طول الحافة أنصاف أقطار تحكّم تحدّدها."
|
||||
},
|
||||
"de": {
|
||||
"ui.copy_details": "Details kopieren",
|
||||
"ui.copy_details_tip": "Die vollständige Meldung kopieren, um sie nachzuschlagen oder zu melden.",
|
||||
"ui.fillet_error_corner": "Die glatte Ecke ließ sich hier nicht bauen. Versuchen Sie einen anderen Rücksprung oder tangentiale Ecken.",
|
||||
"ui.fillet_error_failed": "Diese Rundung ließ sich an diesen Kanten nicht bauen. Versuchen Sie einen anderen Radius oder runden Sie weniger Kanten auf einmal.",
|
||||
"ui.fillet_error_too_large": "Der Radius ist zu groß für die Flächen neben diesen Kanten. Versuchen Sie einen kleineren Radius oder stellen Sie „Wenn zu groß“ auf „Größter passender“.",
|
||||
"ui.fillet_fit": "Wenn zu groß",
|
||||
"ui.fillet_fit_exact": "Anhalten und melden",
|
||||
"ui.fillet_fit_reduce": "Größter passender",
|
||||
"ui.fillet_fit_taper": "Wo nötig verjüngen",
|
||||
"ui.fillet_fit_tip": "Wenn der Radius größer ist, als die Flächen neben einer Kante zulassen: mit einer Meldung anhalten, jede Kante mit dem größten passenden Radius runden oder den Radius behalten und die Rundung nur dort verjüngen, wo die Flächen enden.",
|
||||
"ui.fillet_options_tip": "Tangentenkette: auch die Kanten runden, die glatt an die gewählten anschließen.\nVorschau: das Ergebnis beim Bearbeiten in der Ansicht zeigen.",
|
||||
"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."
|
||||
},
|
||||
"he": {
|
||||
"ui.copy_details": "העתקת פרטים",
|
||||
"ui.copy_details_tip": "העתקת ההודעה המלאה, כדי לחפש אותה או לדווח עליה.",
|
||||
"ui.fillet_error_corner": "לא ניתן היה לבנות כאן את הפינה החלקה. נסו נסיגה אחרת, או פינות משיקות.",
|
||||
"ui.fillet_error_failed": "לא ניתן היה לבנות את העיגול הזה על הקצוות האלה. נסו רדיוס אחר, או עגלו פחות קצוות בבת אחת.",
|
||||
"ui.fillet_error_too_large": "הרדיוס גדול מדי עבור הפאות שליד הקצוות האלה. נסו רדיוס קטן יותר, או בחרו ב„אם גדול מדי” את „להשתמש בגדול ביותר שנכנס”.",
|
||||
"ui.fillet_fit": "אם גדול מדי",
|
||||
"ui.fillet_fit_exact": "לעצור ולהודיע",
|
||||
"ui.fillet_fit_reduce": "להשתמש בגדול ביותר שנכנס",
|
||||
"ui.fillet_fit_taper": "להצר היכן שצריך",
|
||||
"ui.fillet_fit_tip": "כשהרדיוס גדול ממה שהפאות שליד הקצה מאפשרות: לעצור עם הודעה, לעגל כל קצה ברדיוס הגדול ביותר שנכנס, או לשמור על הרדיוס ולהצר את העיגול רק היכן שהפאות נגמרות.",
|
||||
"ui.fillet_options_tip": "שרשרת משיקה: לעגל גם את הקצוות שממשיכים בחלקות מהקצוות שנבחרו.\nתצוגה מקדימה: להציג את התוצאה בתצוגה בזמן העריכה.",
|
||||
"ui.fillet_type_choices_tip": "רדיוס קבוע: רדיוס אחד לכל אורך הקצה.\nאורך מיתר: רוחב העיגול נשאר זהה, בכל זווית שבה הפאות נפגשות.\nרדיוס משתנה: רדיוס בכל קצה, ועוד לאורך הקצה אם מוסיפים.\nרציף בעקמומיות: חתך בלי שבר בהשתקפויות היכן שהוא פוגש פאות מישוריות (G2).\nספליין: הרדיוס לאורך הקצה עוקב אחרי רדיוסי בקרה שאתם קובעים."
|
||||
},
|
||||
"ja": {
|
||||
"ui.copy_details": "詳細をコピー",
|
||||
"ui.copy_details_tip": "調べたり報告したりできるよう、メッセージ全文をコピーします。",
|
||||
"ui.fillet_error_corner": "ここではなめらかなコーナーを作れませんでした。別のセットバックか、接線のコーナーを試してください。",
|
||||
"ui.fillet_error_failed": "このフィレットをこれらのエッジに作れませんでした。別の半径を試すか、一度に丸めるエッジを減らしてください。",
|
||||
"ui.fillet_error_too_large": "これらのエッジの隣の面に対して半径が大きすぎます。小さい半径を試すか、「大きすぎるとき」を「収まる最大の半径を使う」にしてください。",
|
||||
"ui.fillet_fit": "大きすぎるとき",
|
||||
"ui.fillet_fit_exact": "止めて知らせる",
|
||||
"ui.fillet_fit_reduce": "収まる最大の半径を使う",
|
||||
"ui.fillet_fit_taper": "必要な所だけ細くする",
|
||||
"ui.fillet_fit_tip": "半径がエッジの隣の面で収まる大きさを超えるとき: メッセージを出して止める、各エッジを収まる最大の半径で丸める、または半径を保って面が足りない所だけフィレットを細くする。",
|
||||
"ui.fillet_options_tip": "正接チェーン: 選んだエッジからなめらかに続くエッジも丸めます。\nプレビュー: 編集中に結果をビューに表示します。",
|
||||
"ui.fillet_type_choices_tip": "一定の半径: エッジ全体で一つの半径。\n弦長: 面がどんな角度で交わっても、フィレットの幅が同じ。\n可変半径: 両端の半径と、追加すればエッジ沿いの半径。\n曲率連続: 平面と接する所で映り込みが折れない断面 (G2)。\nスプライン: エッジ沿いの半径が、設定した制御半径に従います。"
|
||||
},
|
||||
"ko": {
|
||||
"ui.copy_details": "세부 정보 복사",
|
||||
"ui.copy_details_tip": "찾아보거나 보고할 수 있도록 전체 메시지를 복사합니다.",
|
||||
"ui.fillet_error_corner": "여기에서는 부드러운 코너를 만들 수 없었습니다. 다른 셋백이나 접선 코너를 시도해 보세요.",
|
||||
"ui.fillet_error_failed": "이 모서리들에 이 필렛을 만들 수 없었습니다. 다른 반지름을 시도하거나 한 번에 둥글게 하는 모서리 수를 줄여 보세요.",
|
||||
"ui.fillet_error_too_large": "이 모서리 옆의 면에 비해 반지름이 너무 큽니다. 더 작은 반지름을 시도하거나 '너무 클 때'를 '들어가는 가장 큰 반지름 사용'으로 설정하세요.",
|
||||
"ui.fillet_fit": "너무 클 때",
|
||||
"ui.fillet_fit_exact": "멈추고 알리기",
|
||||
"ui.fillet_fit_reduce": "들어가는 가장 큰 반지름 사용",
|
||||
"ui.fillet_fit_taper": "필요한 곳만 좁히기",
|
||||
"ui.fillet_fit_tip": "반지름이 모서리 옆 면이 허용하는 것보다 클 때: 메시지와 함께 멈추거나, 모든 모서리를 들어가는 가장 큰 반지름으로 둥글게 하거나, 반지름을 유지하고 면이 모자란 곳에서만 라운드를 좁힙니다.",
|
||||
"ui.fillet_options_tip": "접선 체인: 선택한 모서리에서 부드럽게 이어지는 모서리도 둥글게 합니다.\n미리 보기: 편집하는 동안 결과를 뷰에 표시합니다.",
|
||||
"ui.fillet_type_choices_tip": "일정한 반지름: 모서리 전체에 하나의 반지름.\n현 길이: 면이 어떤 각도로 만나든 라운드의 폭이 같습니다.\n가변 반지름: 양 끝의 반지름과, 추가하면 모서리를 따라 더.\n곡률 연속: 평면과 만나는 곳에서 반사가 꺾이지 않는 단면(G2).\n스플라인: 모서리를 따라 반지름이 설정한 제어 반지름을 따릅니다."
|
||||
},
|
||||
"zh": {
|
||||
"ui.copy_details": "复制详细信息",
|
||||
"ui.copy_details_tip": "复制完整消息,以便查找或报告。",
|
||||
"ui.fillet_error_corner": "无法在此处构建平滑角点。请尝试其他后退距离,或相切角点。",
|
||||
"ui.fillet_error_failed": "无法在这些边上构建此圆角。请尝试其他半径,或一次少倒几条边。",
|
||||
"ui.fillet_error_too_large": "半径对这些边旁边的面来说太大。请尝试更小的半径,或将“过大时”设为“使用能容纳的最大半径”。",
|
||||
"ui.fillet_fit": "过大时",
|
||||
"ui.fillet_fit_exact": "停止并提示",
|
||||
"ui.fillet_fit_reduce": "使用能容纳的最大半径",
|
||||
"ui.fillet_fit_taper": "在需要处收窄",
|
||||
"ui.fillet_fit_tip": "当半径大于边旁边的面所允许的大小时:给出提示并停止,用能容纳的最大半径倒每条边,或保持半径、只在面不够的地方收窄圆角。",
|
||||
"ui.fillet_options_tip": "相切链:同时倒圆与所选边平滑相连的边。\n预览:编辑时在视图中显示结果。",
|
||||
"ui.fillet_type_choices_tip": "恒定半径:整条边一个半径。\n弦长:无论面以什么角度相交,圆角宽度都相同。\n可变半径:两端各一个半径,添加后沿边还有更多。\n曲率连续:与平面相接处反射没有折痕的截面 (G2)。\n样条:沿边的半径跟随您设置的控制半径。"
|
||||
}
|
||||
}
|
||||
@@ -2566,17 +2566,59 @@ fn error_rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui: &mut
|
||||
crate::ui_kit::text(ui, &shown_error(&error), crate::ui_kit::Text::Hint("missing_pick"));
|
||||
return;
|
||||
}
|
||||
// A fillet's refusal reads in plain words; the kernel's own text stays in the hover
|
||||
// text, Copy Details and the issue report.
|
||||
let plain = if matches!(d.kind, Kind::Fillet { chamfer: false, .. }) { fillet_plain_error(&error) } else { None };
|
||||
ui.vertical(|ui| {
|
||||
crate::ui_kit::text(ui, &shown_error(&error), crate::ui_kit::Text::Report("error", true));
|
||||
let response = crate::ui_kit::button(ui, "Create Issue…", crate::ui_kit::Button::Normal);
|
||||
crate::scenario::publish_handle("dialog:create_issue", response.rect.center());
|
||||
if response.clicked() {
|
||||
crate::issue_report::begin(app, ctx, Some(d), Some(&error));
|
||||
match &plain {
|
||||
Some(plain) => {
|
||||
crate::ui_kit::text(ui, plain, crate::ui_kit::Text::Report("error", true)).on_hover_text(shown_error(&error));
|
||||
}
|
||||
None => {
|
||||
crate::ui_kit::text(ui, &shown_error(&error), crate::ui_kit::Text::Report("error", true));
|
||||
}
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if plain.is_some() {
|
||||
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());
|
||||
if copy.on_hover_text(tr("ui.copy_details_tip")).clicked() {
|
||||
ui.ctx().copy_text(error.clone());
|
||||
}
|
||||
}
|
||||
let response = crate::ui_kit::button(ui, "Create Issue…", crate::ui_kit::Button::Normal);
|
||||
crate::scenario::publish_handle("dialog:create_issue", response.rect.center());
|
||||
if response.clicked() {
|
||||
crate::issue_report::begin(app, ctx, Some(d), Some(&error));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// A fillet's refusal by the kernel in plain words, for the dialog: what happened and what may
|
||||
/// help, without SMLib's status codes, its check's test list or the command's parameter names.
|
||||
/// `None` for a refusal that is no kernel failure (an expression, a pick), shown as it is.
|
||||
pub(crate) fn fillet_plain_error(error: &str) -> Option<&'static str> {
|
||||
// The kernel's refusals as the timeline records them (`KernelError`, `blend_smlib`).
|
||||
let too_large = error.contains("larger than the faces beside its edges allow");
|
||||
let corner = error.contains("setback") || error.contains("G2 corner") || error.contains("corner patch");
|
||||
let kernel = error.contains("SMLib")
|
||||
|| error.contains("the operation failed")
|
||||
|| error.contains("not supported yet")
|
||||
|| error.contains("kernel internal error")
|
||||
|| error.contains("the rounds would leave openings");
|
||||
if too_large {
|
||||
Some(tr("ui.fillet_error_too_large"))
|
||||
} else if corner && kernel {
|
||||
Some(tr("ui.fillet_error_corner"))
|
||||
} else if kernel {
|
||||
Some(tr("ui.fillet_error_failed"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A refusal as the dialog shows it: without the engine's "invalid parameters for `id`: "
|
||||
/// prefix (the dialog already names the command), and in the interface language when the
|
||||
/// message is in the catalogs.
|
||||
@@ -4067,7 +4109,10 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
}
|
||||
// A smooth patch's setback, typed once, goes to each of the dialog's corners; left
|
||||
// empty, the command sets them back by its default (1.5 × the radius).
|
||||
if d.extra.get("corner").and_then(Value::as_str) == Some("g2") && !setback.trim().is_empty() {
|
||||
if d.extra.get("corner").and_then(Value::as_str) == Some("g2")
|
||||
&& dense::fillet_option_applies(&d.extra, "corner")
|
||||
&& !setback.trim().is_empty()
|
||||
{
|
||||
let corners = fillet_corners(s, sels(d, 0));
|
||||
if !corners.is_empty() {
|
||||
p["setbacks"] = Value::Array(corners.into_iter().map(|v| json!({"vertex": pt3(v), "distance": setback.trim()})).collect());
|
||||
@@ -4605,7 +4650,12 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
|
||||
let mut params = params;
|
||||
d.kind.fill_simple_params(&mut params);
|
||||
if let Value::Object(m) = &mut params {
|
||||
// A fillet option that doesn't apply with the others is never sent.
|
||||
let fillet = matches!(d.kind, Kind::Fillet { chamfer: false, .. });
|
||||
for (k, v) in &d.extra {
|
||||
if fillet && !dense::fillet_option_applies(&d.extra, k) {
|
||||
continue;
|
||||
}
|
||||
m.entry(k.clone()).or_insert_with(|| v.clone());
|
||||
}
|
||||
}
|
||||
|
||||
+215
-136
@@ -122,48 +122,110 @@ fn fillet_corner_tip() -> String {
|
||||
FILLET_CORNERS.map(|(_, tip)| crate::i18n::format(tip, &[("factor", SETBACK_FACTOR)])).join("\n")
|
||||
}
|
||||
|
||||
/// What `solid.fillet` does with a radius too large for the faces beside an edge (`fit`): stop
|
||||
/// and say so (the default), round every edge with the largest radius that fits, or keep the
|
||||
/// 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")];
|
||||
|
||||
/// Which fillet options apply together, exactly as `solid.fillet` takes them (`fillet` in
|
||||
/// crates/engine/src/cmd/features.rs):
|
||||
///
|
||||
/// | Type | If too large (`fit`) | Corners (`corner`) | Setback (`setbacks`) |
|
||||
/// |---------------|-------------------------|-------------------------------------|----------------------|
|
||||
/// | Constant | exact, reduce or taper | tangent; curvature with exact only | with curvature only |
|
||||
/// | any other | exact (not sent) | tangent (not sent) | none |
|
||||
///
|
||||
/// 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`]).
|
||||
pub(super) fn fillet_option_applies(extra: &serde_json::Map<String, Value>, key: &str) -> bool {
|
||||
let constant = extra.get("type").and_then(Value::as_str).is_none_or(|t| t == "constant");
|
||||
let exact = extra.get("fit").and_then(Value::as_str).is_none_or(|fit| fit == "exact");
|
||||
match key {
|
||||
"fit" => constant,
|
||||
"corner" | "setbacks" => constant && exact,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// After `changed` (`fit` or `corner`) took a new value, the options it rules out back to their
|
||||
/// defaults: a fitted radius (`fit` reduce or taper) takes tangent corners, a curvature corner
|
||||
/// 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 String) {
|
||||
let fitted = extra.get("fit").and_then(Value::as_str).is_some_and(|fit| fit != "exact");
|
||||
let patch = extra.get("corner").and_then(Value::as_str) == Some("g2");
|
||||
match changed {
|
||||
"fit" if fitted => {
|
||||
extra.remove("corner");
|
||||
extra.remove("setbacks");
|
||||
setback.clear();
|
||||
}
|
||||
"corner" if patch => {
|
||||
extra.remove("fit");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A fillet's Corners row (`corner`) and, with a smooth patch, its Setback (`setbacks`, one
|
||||
/// distance for every corner). Shown only while the edges have a corner where three or more
|
||||
/// rounded edges meet, or the fillet already has a smooth patch; a patch takes Constant radius
|
||||
/// and Exact fit.
|
||||
/// distance for every corner), for a Constant radius fillet. Shown only while the edges have a
|
||||
/// corner where three or more rounded edges meet, or the fillet already has a smooth patch.
|
||||
fn fillet_corner_rows(
|
||||
ui: &mut egui::Ui,
|
||||
s: &solidcraft_engine::Session,
|
||||
extra: &mut serde_json::Map<String, Value>,
|
||||
inputs: &[selection::SelInput],
|
||||
rtype: usize,
|
||||
setback: &mut String,
|
||||
) -> bool {
|
||||
let patch = extra.get("corner").and_then(Value::as_str) == Some("g2");
|
||||
if !patch && fillet_corners(s, selection::items(inputs, 0)).is_empty() {
|
||||
return false;
|
||||
}
|
||||
let exact = extra.get("fit").and_then(Value::as_str).is_none_or(|fit| fit == "exact");
|
||||
let mut enter = false;
|
||||
ui.add_enabled_ui(rtype == 0 && exact, |ui| {
|
||||
let mut corner = if patch { Continuity::G2 } else { Continuity::G1 };
|
||||
ui_kit::continuity_row_explained(ui, "ui.corners", "fi_corner", &mut corner, &FILLET_CORNERS.map(|(g, _)| g), &fillet_corner_tip());
|
||||
if (corner == Continuity::G2) != patch {
|
||||
if corner == Continuity::G2 {
|
||||
extra.insert("corner".into(), json!(Continuity::G2.as_str()));
|
||||
} else {
|
||||
extra.remove("corner");
|
||||
}
|
||||
let mut corner = if patch { Continuity::G2 } else { Continuity::G1 };
|
||||
ui_kit::continuity_row_explained(ui, "ui.corners", "fi_corner", &mut corner, &FILLET_CORNERS.map(|(g, _)| g), &fillet_corner_tip());
|
||||
if (corner == Continuity::G2) != patch {
|
||||
if corner == Continuity::G2 {
|
||||
extra.insert("corner".into(), json!(Continuity::G2.as_str()));
|
||||
} else {
|
||||
extra.remove("corner");
|
||||
}
|
||||
if extra.get("corner").and_then(Value::as_str) == Some("g2") {
|
||||
ui.scope(|ui| {
|
||||
let hint = crate::i18n::format("ui.fillet_setback_hint", &[("factor", SETBACK_FACTOR)]);
|
||||
enter |= hint_row(ui, "ui.fillet_setback", setback, &hint);
|
||||
})
|
||||
.response
|
||||
.on_hover_text(crate::i18n::format("ui.fillet_setback_tip", &[("factor", SETBACK_FACTOR)]));
|
||||
}
|
||||
})
|
||||
.response
|
||||
.on_disabled_hover_text(tr("ui.fillet_corner_needs_constant"));
|
||||
settle_fillet_options(extra, "corner", setback);
|
||||
}
|
||||
if extra.get("corner").and_then(Value::as_str) == Some("g2") {
|
||||
ui.scope(|ui| {
|
||||
let hint = crate::i18n::format("ui.fillet_setback_hint", &[("factor", SETBACK_FACTOR)]);
|
||||
enter |= hint_row(ui, "ui.fillet_setback", setback, &hint);
|
||||
})
|
||||
.response
|
||||
.on_hover_text(crate::i18n::format("ui.fillet_setback_tip", &[("factor", SETBACK_FACTOR)]));
|
||||
}
|
||||
enter
|
||||
}
|
||||
|
||||
/// A Constant radius fillet's "If too large" row (`fit`), with the radius a reduced fillet used.
|
||||
fn fillet_fit_rows(app: &SolveApp, ui: &mut egui::Ui, extra: &mut serde_json::Map<String, Value>, setback: &mut String) {
|
||||
let t = Tokens::get();
|
||||
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)));
|
||||
row.response.on_hover_text(tr("ui.fillet_fit_tip"));
|
||||
let fit = row.inner;
|
||||
if extra.get("fit") != before.as_ref() {
|
||||
settle_fillet_options(extra, "fit", setback);
|
||||
}
|
||||
if fit == 1
|
||||
&& let Some((lo, hi)) = app.preview.applied
|
||||
{
|
||||
form_row(ui, "Applied Radius", |ui| {
|
||||
let shown = if hi > lo {
|
||||
format!("{} – {}", crate::prefs::show_mm_bare(app, lo), crate::prefs::show_mm(app, hi, 1))
|
||||
} else {
|
||||
crate::prefs::show_mm(app, lo, 1)
|
||||
};
|
||||
ui.label(RichText::new(shown).color(t.warning));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// The most instances a solid pattern makes in one direction (the document's own limit).
|
||||
const MAX_PATTERN_INSTANCES: i64 = 1000;
|
||||
|
||||
@@ -414,8 +476,11 @@ pub(super) fn rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui:
|
||||
}
|
||||
response
|
||||
})
|
||||
.on_hover_text(tr("ui.fillet_type_tip"));
|
||||
enter |= fillet_corner_rows(ui, &app.session, &mut d.extra, &d.inputs, rtype, setback);
|
||||
.on_hover_text(tr("ui.fillet_type_choices_tip"));
|
||||
if rtype == 0 {
|
||||
enter |= fillet_corner_rows(ui, &app.session, &mut d.extra, &d.inputs, setback);
|
||||
fillet_fit_rows(app, ui, &mut d.extra, setback);
|
||||
}
|
||||
}
|
||||
if *chamfer && *ctype == 1 {
|
||||
enter |= expr_row(ui, "Distance 2", distance2);
|
||||
@@ -427,103 +492,70 @@ pub(super) fn rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui:
|
||||
form_row(ui, "Flip", |ui| check(ui, flip));
|
||||
}
|
||||
ui_kit::section(ui, "fillet_options", "Options", true, |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
|
||||
.on_hover_text(tr("ui.fillet_options_tip"));
|
||||
});
|
||||
if !*chamfer {
|
||||
ui.add_enabled_ui(matches!(rtype, 2 | 4), |ui| {
|
||||
ui_kit::section(ui, "variable_radius", "Variable Radius", false, |ui| {
|
||||
if !matches!(rtype, 2 | 4) {
|
||||
ui.add_enabled(false, egui::Label::new(tr("Choose Variable radius or Spline in Type.")).wrap());
|
||||
}
|
||||
if rtype == 2 {
|
||||
form_row(ui, "End Radius", |ui| {
|
||||
enter |= extra_field(ui, &mut d.extra, "radius2", &length_default(&app.session, "1"));
|
||||
});
|
||||
// Which end of the first edge takes the start radius.
|
||||
form_row(ui, "Swap Ends", |ui| {
|
||||
extra_check(ui, &mut d.extra, "swap_ends");
|
||||
});
|
||||
// Radii between the ends: position along the edge (from the start end)
|
||||
// and radius.
|
||||
let mut mids = mid_points(&d.extra);
|
||||
let mut remove = None;
|
||||
for (k, (position, r)) in mids.iter_mut().enumerate() {
|
||||
form_row(ui, "Mid Radius", |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui_kit::fraction_field(ui, position)
|
||||
.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);
|
||||
ui.allocate_ui_with_layout(vec2(width, t.space_24), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
enter |= field(ui, r);
|
||||
});
|
||||
if ui_kit::button(ui, "×", ui_kit::Button::Small).on_hover_text(tr("Remove this point")).clicked() {
|
||||
remove = Some(k);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if let Some(k) = remove {
|
||||
mids.remove(k);
|
||||
}
|
||||
form_row(ui, "", |ui| {
|
||||
if ui_kit::button(ui, tr("Add Mid Radius"), ui_kit::Button::Normal).clicked() && mids.len() < 20 {
|
||||
let t = if mids.is_empty() { 0.5 } else { (mids.iter().map(|m| m.0).fold(0.0, f64::max) + 1.0) * 0.5 };
|
||||
mids.push((t.min(0.99), radius.clone()));
|
||||
}
|
||||
});
|
||||
d.extra.insert("mid".into(), json!(mids.iter().map(|(t, r)| json!([t, r])).collect::<Vec<_>>()));
|
||||
// How the radius runs between the radii: smoothly, or straight.
|
||||
let was = d.extra.get("smooth").and_then(Value::as_bool).unwrap_or(false);
|
||||
let mut smooth = was;
|
||||
ui_kit::checkbox_grid(ui, &mut [("ui.fillet_smooth_radius", &mut smooth)], 1);
|
||||
if smooth != was {
|
||||
d.extra.insert("smooth".into(), json!(smooth));
|
||||
}
|
||||
}
|
||||
if rtype == 4 {
|
||||
// The radius along the edge as a B-spline: its control radii from the
|
||||
// start end, also dragged by their handles on the edge.
|
||||
form_row(ui, "Swap Ends", |ui| {
|
||||
extra_check(ui, &mut d.extra, "swap_ends");
|
||||
});
|
||||
let mut radii = spline_radii(&mut d.extra, radius);
|
||||
let n = radii.len();
|
||||
for (k, r) in radii.iter_mut().enumerate() {
|
||||
enter |= expr_row(ui, if k + 1 == n { "End Radius" } else { "Control Radius" }, r);
|
||||
}
|
||||
d.extra.insert("radii".into(), json!(radii));
|
||||
}
|
||||
});
|
||||
})
|
||||
.response
|
||||
.on_disabled_hover_text(tr("Choose Variable radius or Spline in Type."));
|
||||
ui_kit::section(ui, "fillet_advanced", "Advanced", false, |ui| {
|
||||
let constant = rtype == 0;
|
||||
ui.add_enabled_ui(constant, |ui| {
|
||||
let fit = form_row(ui, "Radius Fit", |ui| {
|
||||
extra_choice(
|
||||
ui,
|
||||
&mut d.extra,
|
||||
"fi_fit",
|
||||
"fit",
|
||||
&[("Exact", "exact"), ("Reduce to Fit", "reduce"), ("Taper to Fit", "taper")],
|
||||
)
|
||||
// The radii along the edge, for the two types that take them.
|
||||
if !*chamfer && matches!(rtype, 2 | 4) {
|
||||
ui_kit::section(ui, "variable_radius", "Variable Radius", true, |ui| {
|
||||
if rtype == 2 {
|
||||
form_row(ui, "End Radius", |ui| {
|
||||
enter |= extra_field(ui, &mut d.extra, "radius2", &length_default(&app.session, "1"));
|
||||
});
|
||||
if fit == 1
|
||||
&& let Some((lo, hi)) = app.preview.applied
|
||||
{
|
||||
form_row(ui, "Applied Radius", |ui| {
|
||||
let shown = if hi > lo {
|
||||
format!("{} – {}", crate::prefs::show_mm_bare(app, lo), crate::prefs::show_mm(app, hi, 1))
|
||||
} else {
|
||||
crate::prefs::show_mm(app, lo, 1)
|
||||
};
|
||||
ui.label(RichText::new(shown).color(t.warning));
|
||||
// Which end of the first edge takes the start radius.
|
||||
form_row(ui, "Swap Ends", |ui| {
|
||||
extra_check(ui, &mut d.extra, "swap_ends");
|
||||
});
|
||||
// Radii between the ends: position along the edge (from the start end)
|
||||
// and radius.
|
||||
let mut mids = mid_points(&d.extra);
|
||||
let mut remove = None;
|
||||
for (k, (position, r)) in mids.iter_mut().enumerate() {
|
||||
form_row(ui, "Mid Radius", |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui_kit::fraction_field(ui, position)
|
||||
.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);
|
||||
ui.allocate_ui_with_layout(vec2(width, t.space_24), egui::Layout::left_to_right(egui::Align::Center), |ui| {
|
||||
enter |= field(ui, r);
|
||||
});
|
||||
if ui_kit::button(ui, "×", ui_kit::Button::Small).on_hover_text(tr("Remove this point")).clicked() {
|
||||
remove = Some(k);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
if !constant {
|
||||
ui.label(RichText::new(tr("ui.fillet_fit_needs_constant")).color(t.text_dim).size(t.font_small));
|
||||
if let Some(k) = remove {
|
||||
mids.remove(k);
|
||||
}
|
||||
form_row(ui, "", |ui| {
|
||||
if ui_kit::button(ui, tr("Add Mid Radius"), ui_kit::Button::Normal).clicked() && mids.len() < 20 {
|
||||
let t = if mids.is_empty() { 0.5 } else { (mids.iter().map(|m| m.0).fold(0.0, f64::max) + 1.0) * 0.5 };
|
||||
mids.push((t.min(0.99), radius.clone()));
|
||||
}
|
||||
});
|
||||
d.extra.insert("mid".into(), json!(mids.iter().map(|(t, r)| json!([t, r])).collect::<Vec<_>>()));
|
||||
// How the radius runs between the radii: smoothly, or straight.
|
||||
let was = d.extra.get("smooth").and_then(Value::as_bool).unwrap_or(false);
|
||||
let mut smooth = was;
|
||||
ui_kit::checkbox_grid(ui, &mut [("ui.fillet_smooth_radius", &mut smooth)], 1);
|
||||
if smooth != was {
|
||||
d.extra.insert("smooth".into(), json!(smooth));
|
||||
}
|
||||
}
|
||||
if rtype == 4 {
|
||||
// The radius along the edge as a B-spline: its control radii from the
|
||||
// start end, also dragged by their handles on the edge.
|
||||
form_row(ui, "Swap Ends", |ui| {
|
||||
extra_check(ui, &mut d.extra, "swap_ends");
|
||||
});
|
||||
let mut radii = spline_radii(&mut d.extra, radius);
|
||||
let n = radii.len();
|
||||
for (k, r) in radii.iter_mut().enumerate() {
|
||||
enter |= expr_row(ui, if k + 1 == n { "End Radius" } else { "Control Radius" }, r);
|
||||
}
|
||||
d.extra.insert("radii".into(), json!(radii));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1147,17 +1179,69 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advanced_controls_map_to_fit() {
|
||||
fn if_too_large_maps_to_fit() {
|
||||
let mut h = fillet_harness();
|
||||
assert!(scenario::handle_at("dialog:Radius Fit").is_none(), "Advanced starts collapsed");
|
||||
h.step(&json!({"click":{"handle":"dialog:section:Advanced"}})).unwrap();
|
||||
assert!(scenario::handle_at("dialog:Corner").is_none(), "the corner is no longer an advanced choice");
|
||||
choose(&mut h, "Radius Fit", "Reduce to Fit");
|
||||
assert!(scenario::handle_at("dialog:section:Advanced").is_none(), "no Advanced section: its one row sits with Type");
|
||||
choose(&mut h, "ui.fillet_fit", "ui.fillet_fit_reduce");
|
||||
assert_eq!(parameters(&h)["fit"], json!("reduce"));
|
||||
choose(&mut h, "Radius Fit", "Taper to Fit");
|
||||
choose(&mut h, "ui.fillet_fit", "ui.fillet_fit_taper");
|
||||
assert_eq!(parameters(&h)["fit"], json!("taper"));
|
||||
choose(&mut h, "Radius Fit", "Exact");
|
||||
choose(&mut h, "ui.fillet_fit", "ui.fillet_fit_exact");
|
||||
assert!(parameters(&h).get("fit").is_none());
|
||||
choose(&mut h, "Type", "Chord length");
|
||||
assert!(scenario::handle_at("dialog:ui.fillet_fit").is_none(), "a fitted radius is for Constant radius only");
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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.
|
||||
#[test]
|
||||
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");
|
||||
// Each distinct command is run once.
|
||||
let ran = std::cell::RefCell::new(std::collections::BTreeSet::new());
|
||||
let check = |h: &Harness, what: &str| {
|
||||
let p = parameters(h);
|
||||
let constant = p.get("type").is_none_or(|t| t == "constant");
|
||||
let exact = p.get("fit").is_none_or(|f| f == "exact");
|
||||
assert!(constant || p.get("fit").is_none(), "{what}: fit without Constant radius: {p}");
|
||||
assert!(constant && exact || (p.get("corner").is_none() && p.get("setbacks").is_none()), "{what}: a curvature corner with {p}");
|
||||
assert_eq!(scenario::handle_at("dialog:ui.fillet_fit").is_some(), constant, "{what}: If too large shows exactly with Constant radius");
|
||||
assert_eq!(scenario::handle_at("dialog:ui.corners").is_some(), constant, "{what}: Corners shows exactly with Constant radius");
|
||||
if !ran.borrow_mut().insert(p.to_string()) {
|
||||
return;
|
||||
}
|
||||
let mut copy = solidcraft_engine::Session::new((*h.app.session.doc).clone());
|
||||
if let Err(e) = copy.execute("solid.fillet", &p) {
|
||||
assert!(!refused_for_options(&e.to_string()), "{what}: {p} refused for its options: {e}");
|
||||
}
|
||||
};
|
||||
for (label, _) in FILLET_TYPES {
|
||||
for (fit, _) in FILLET_FITS {
|
||||
for corner_first in [false, true] {
|
||||
let mut h = fillet_harness();
|
||||
set_edges(&mut h, 3);
|
||||
let what = format!("{label}, {fit}, corner first: {corner_first}");
|
||||
if corner_first {
|
||||
choose(&mut h, "ui.corners", Continuity::G2.label_id());
|
||||
check(&h, &what);
|
||||
choose(&mut h, "ui.fillet_fit", fit);
|
||||
if fit != "ui.fillet_fit_exact" {
|
||||
assert!(parameters(&h).get("corner").is_none(), "{what}: a fitted radius puts Corners back to Tangent");
|
||||
}
|
||||
} else {
|
||||
choose(&mut h, "ui.fillet_fit", fit);
|
||||
check(&h, &what);
|
||||
choose(&mut h, "ui.corners", Continuity::G2.label_id());
|
||||
assert!(parameters(&h).get("fit").is_none(), "{what}: a curvature corner puts If too large back to Stop");
|
||||
}
|
||||
check(&h, &what);
|
||||
choose(&mut h, "Type", label);
|
||||
check(&h, &what);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The three edges of the sample's corner at (10, 0, 5): top front, top right and upright.
|
||||
@@ -1205,12 +1289,12 @@ mod tests {
|
||||
let p = parameters(&h);
|
||||
assert!(p.get("corner").is_none() && p.get("setbacks").is_none(), "{p}");
|
||||
assert!(scenario::handle_at("dialog:ui.fillet_setback").is_none());
|
||||
// Another type clears the patch; the row stays, disabled, saying why in its hover text.
|
||||
// Another type clears the patch and hides the row: its corners are the rolling ball's.
|
||||
choose(&mut h, "ui.corners", Continuity::G2.label_id());
|
||||
choose(&mut h, "Type", "Chord length");
|
||||
let p = parameters(&h);
|
||||
assert!(p.get("corner").is_none() && p.get("setbacks").is_none(), "{p}");
|
||||
assert!(scenario::handle_at("dialog:ui.corners").is_some());
|
||||
assert!(scenario::handle_at("dialog:ui.corners").is_none());
|
||||
}
|
||||
|
||||
/// No visible label of the fillet dialog names a continuity by its code (G0 … G3): the words
|
||||
@@ -1229,11 +1313,8 @@ mod tests {
|
||||
let mut h = fillet_harness();
|
||||
set_edges(&mut h, 3);
|
||||
choose(&mut h, "ui.corners", Continuity::G2.label_id());
|
||||
for section in ["Variable Radius", "Advanced"] {
|
||||
h.step(&json!({"click":{"handle":format!("dialog:section:{section}")}})).unwrap();
|
||||
}
|
||||
h.frames(3);
|
||||
assert!(scenario::handle_at("dialog:ui.fillet_setback").is_some() && scenario::handle_at("dialog:Radius Fit").is_some());
|
||||
assert!(scenario::handle_at("dialog:ui.fillet_setback").is_some() && scenario::handle_at("dialog:ui.fillet_fit").is_some());
|
||||
let tree = h.output.accesskit_update.as_ref().unwrap();
|
||||
let labels: Vec<String> = tree.nodes.iter().flat_map(|(_, node)| [node.label(), node.value()]).flatten().map(str::to_string).collect();
|
||||
assert!(labels.iter().any(|l| l == "Curvature"), "{labels:?}");
|
||||
@@ -1289,9 +1370,7 @@ mod tests {
|
||||
assert_eq!(parameters(&h), original);
|
||||
assert!(scenario::handle_at("dialog:End Radius").is_none());
|
||||
choose(&mut h, "Type", "Variable radius");
|
||||
assert!(scenario::handle_at("dialog:End Radius").is_none(), "the section stays collapsed after a type choice");
|
||||
h.step(&json!({"click":{"handle":"dialog:section:Variable Radius"}})).unwrap();
|
||||
assert!(scenario::handle_at("dialog:End Radius").is_some());
|
||||
assert!(scenario::handle_at("dialog:End Radius").is_some(), "the section shows, open, once its type is chosen");
|
||||
assert!(
|
||||
scenario::handle_at("dialog:ui.fillet_smooth_radius").is_some(),
|
||||
"Smooth radius change is a supported variable-radius option even before adding a middle radius"
|
||||
|
||||
Reference in New Issue
Block a user