Fillet dialog: plain words for continuity, Corners with Setback out of Advanced

The Continuity G1|G2 control repeated Type (both set `type`) and G1 silently cleared the
corner; it is gone, and Type names the section in words: "Curvature continuous", its code (G2)
in the row's hover text. The vertex choice moves out of Advanced as Corners: Ball | Smooth
patch, shown only while the edges have a corner where three or more rounded edges meet (or the
fillet already has a smooth patch), enabled for Constant radius with Exact fit and saying why
when not. Smooth patch shows Setback: one distance for every corner, sent as `setbacks` at each
corner's vertex; left empty, the command's default (1.5 x the radius) applies. A fillet saved
with setbacks reopens with them: one distance at exactly the dialog's corners shows in Setback,
any other list is kept as saved. "Smooth" in Variable Radius is "Smooth radius change". The
stale "G2 corner construction is not supported yet" tooltip and the "Fit and G2 corners" note
are replaced by new messages (en, ar, de, he, ja, ko, zh).

No command parameter changes; saved designs open unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LUXeKMoxpM7Vuv8Rb6E3K2
This commit is contained in:
jyviko
2026-10-27 11:12:59 -04:00
committed by Irakli Kourtis
parent 676919d4d6
commit 1acbe2806f
4 changed files with 402 additions and 45 deletions
@@ -0,0 +1,100 @@
{
"en": {
"ui.fillet_corner_ball": "Ball",
"ui.fillet_corner_ball_tip": "A piece of the rolling ball's sphere, tangent (G1) to each round.",
"ui.fillet_corner_needs_constant": "Smooth patch corners (G2) need Constant radius and Exact fit.",
"ui.fillet_corner_patch": "Smooth patch",
"ui.fillet_corner_patch_tip": "Curvature-continuous (G2): the corner is set back along each edge and filled with a patch, so reflections run through it without a kink.",
"ui.fillet_fit_needs_constant": "Radius fit needs Constant radius.",
"ui.fillet_setback": "Setback",
"ui.fillet_setback_hint": "{factor} × radius",
"ui.fillet_setback_tip": "How far along each edge from the corner the patch starts. Empty: {factor} × the radius.",
"ui.fillet_smooth_radius": "Smooth radius change",
"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."
},
"ar": {
"ui.fillet_corner_ball": "كرة",
"ui.fillet_corner_ball_tip": "جزء من سطح الكرة المتدحرجة، مماسّ (G1) لكل تقويس.",
"ui.fillet_corner_needs_constant": "زوايا الرقعة الناعمة (G2) تتطلب نصف قطر ثابتًا وملاءمة دقيقة لنصف القطر.",
"ui.fillet_corner_patch": "رقعة ناعمة",
"ui.fillet_corner_patch_tip": "متصل الانحناء (G2): تُرجَع الزاوية على طول كل حافة وتُملأ برقعة، فتمر الانعكاسات عبرها دون انكسار.",
"ui.fillet_fit_needs_constant": "ملاءمة نصف القطر تتطلب نصف قطر ثابتًا.",
"ui.fillet_setback": "مسافة الإرجاع",
"ui.fillet_setback_hint": "{factor} × نصف القطر",
"ui.fillet_setback_tip": "المسافة على طول كل حافة من الزاوية التي تبدأ عندها الرقعة. فارغ: {factor} ضعف نصف القطر.",
"ui.fillet_smooth_radius": "تغيّر ناعم لنصف القطر",
"ui.fillet_type_curvature": "متصل الانحناء",
"ui.fillet_type_tip": "المقطع عبر الحافة. متصل الانحناء: مقطع متصل الانحناء (G2) بين وجوه مستوية، فتمر الانعكاسات من الوجوه إلى التقويس دون انكسار."
},
"de": {
"ui.fillet_corner_ball": "Kugel",
"ui.fillet_corner_ball_tip": "Ein Stück der Kugel des rollenden Balls, tangential (G1) zu jeder Rundung.",
"ui.fillet_corner_needs_constant": "Ecken mit glattem Flächenstück (G2) brauchen einen konstanten Radius und exakte Radiusanpassung.",
"ui.fillet_corner_patch": "Glattes Flächenstück",
"ui.fillet_corner_patch_tip": "Krümmungsstetig (G2): Die Ecke wird entlang jeder Kante zurückgesetzt und mit einem Flächenstück gefüllt, sodass Spiegelungen ohne Knick hindurchlaufen.",
"ui.fillet_fit_needs_constant": "Radiusanpassung braucht einen konstanten Radius.",
"ui.fillet_setback": "Rücksprung",
"ui.fillet_setback_hint": "{factor} × Radius",
"ui.fillet_setback_tip": "Wie weit von der Ecke entlang jeder Kante das Flächenstück beginnt. Leer: {factor} × der Radius.",
"ui.fillet_smooth_radius": "Radius glatt ändern",
"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."
},
"he": {
"ui.fillet_corner_ball": "כדור",
"ui.fillet_corner_ball_tip": "חלק מספירת הכדור המתגלגל, משיק (G1) לכל עיגול.",
"ui.fillet_corner_needs_constant": "פינות עם טלאי חלק (G2) דורשות רדיוס קבוע והתאמת רדיוס מדויקת.",
"ui.fillet_corner_patch": "טלאי חלק",
"ui.fillet_corner_patch_tip": "רציף בעקמומיות (G2): הפינה נסוגה לאורך כל קצה ומתמלאת בטלאי, כך שההשתקפויות עוברות בה בלי שבר.",
"ui.fillet_fit_needs_constant": "התאמת רדיוס דורשת רדיוס קבוע.",
"ui.fillet_setback": "נסיגה",
"ui.fillet_setback_hint": "{factor} × רדיוס",
"ui.fillet_setback_tip": "המרחק לאורך כל קצה מהפינה שבו הטלאי מתחיל. ריק: פי {factor} מהרדיוס.",
"ui.fillet_smooth_radius": "שינוי רדיוס חלק",
"ui.fillet_type_curvature": "רציף בעקמומיות",
"ui.fillet_type_tip": "החתך לרוחב הקצה. רציף בעקמומיות: חתך רציף בעקמומיות (G2) בין פאות מישוריות, כך שההשתקפויות עוברות מהפאות אל העיגול בלי שבר."
},
"ja": {
"ui.fillet_corner_ball": "ボール",
"ui.fillet_corner_ball_tip": "転がるボールの球面の一部で、各フィレットに接します (G1)。",
"ui.fillet_corner_needs_constant": "スムーズパッチのコーナー (G2) には、一定の半径と厳密な半径フィットが必要です。",
"ui.fillet_corner_patch": "スムーズパッチ",
"ui.fillet_corner_patch_tip": "曲率連続 (G2): コーナーを各エッジに沿って後退させてパッチで埋め、映り込みが折れずに通ります。",
"ui.fillet_fit_needs_constant": "半径フィットには一定の半径が必要です。",
"ui.fillet_setback": "セットバック",
"ui.fillet_setback_hint": "{factor} × 半径",
"ui.fillet_setback_tip": "パッチが始まる、コーナーから各エッジに沿った距離。空欄のときは半径の {factor} 倍。",
"ui.fillet_smooth_radius": "半径をなめらかに変化",
"ui.fillet_type_curvature": "曲率連続",
"ui.fillet_type_tip": "エッジを横切る断面。曲率連続: 平面の間の曲率連続 (G2) 断面で、映り込みが面からフィレットへ折れずにつながります。"
},
"ko": {
"ui.fillet_corner_ball": "볼",
"ui.fillet_corner_ball_tip": "굴러가는 볼의 구면 일부로, 각 라운드에 접합니다(G1).",
"ui.fillet_corner_needs_constant": "부드러운 패치 코너(G2)에는 일정한 반지름과 정확한 반지름 맞춤이 필요합니다.",
"ui.fillet_corner_patch": "부드러운 패치",
"ui.fillet_corner_patch_tip": "곡률 연속(G2): 코너를 각 모서리를 따라 물려 패치로 채우므로, 반사가 꺾임 없이 지나갑니다.",
"ui.fillet_fit_needs_constant": "반지름 맞춤에는 일정한 반지름이 필요합니다.",
"ui.fillet_setback": "셋백",
"ui.fillet_setback_hint": "{factor} × 반지름",
"ui.fillet_setback_tip": "패치가 시작되는, 코너에서 각 모서리를 따른 거리. 비워 두면 반지름의 {factor}배.",
"ui.fillet_smooth_radius": "반지름 부드럽게 변화",
"ui.fillet_type_curvature": "곡률 연속",
"ui.fillet_type_tip": "모서리를 가로지르는 단면. 곡률 연속: 평면 사이의 곡률 연속(G2) 단면으로, 반사가 면에서 라운드로 꺾임 없이 이어집니다."
},
"zh": {
"ui.fillet_corner_ball": "球面",
"ui.fillet_corner_ball_tip": "滚球球面的一部分,与每个圆角相切 (G1)。",
"ui.fillet_corner_needs_constant": "平滑曲面片角点 (G2) 需要恒定半径和精确的半径拟合。",
"ui.fillet_corner_patch": "平滑曲面片",
"ui.fillet_corner_patch_tip": "曲率连续 (G2):角点沿每条边后退,并用曲面片填充,反射穿过时没有折痕。",
"ui.fillet_fit_needs_constant": "半径拟合需要恒定半径。",
"ui.fillet_setback": "后退距离",
"ui.fillet_setback_hint": "{factor} × 半径",
"ui.fillet_setback_tip": "曲面片从角点沿每条边开始的距离。留空:半径的 {factor} 倍。",
"ui.fillet_smooth_radius": "半径平滑变化",
"ui.fillet_type_curvature": "曲率连续",
"ui.fillet_type_tip": "横跨边的截面。曲率连续:平面之间的曲率连续 (G2) 截面,反射从面平滑过渡到圆角,没有折痕。"
}
}
+105 -2
View File
@@ -188,6 +188,9 @@ crate::dialog_descriptor::dialog_descriptors! {
distance2: String,
angle: String,
flip: bool,
/// A smooth-patch corner's setback along each edge, as typed; empty for the
/// command's own default (1.5 × the radius).
setback: String,
},
Mirror,
/// Rectangular pattern of bodies: along one direction, optionally a second.
@@ -1631,6 +1634,72 @@ fn fillet_edges(s: &Session, sel: &Sel, chain: bool) -> Vec<Sel> {
picked
}
/// How close two edge ends are to be one vertex (mm).
const VERTEX_TOLERANCE: f64 = 1e-6;
/// The B-rep edges a Fillet dialog rounds, by body and index: the picked edges, and each edge of a
/// picked face but its seams.
fn fillet_edge_ids(s: &Session, items: &[Sel]) -> Vec<(String, usize)> {
let st = crate::open_meshing::session_world(s);
let mut ids: Vec<(String, usize)> = Vec::new();
for x in items {
match x {
Sel::Edge { body, index, .. } => ids.push((body.clone(), *index)),
Sel::Face { body, index, .. } => {
if let Some(b) = st.body(body) {
let m = b.mesh();
for e in m.face_edges(u32::try_from(*index).unwrap_or(u32::MAX)) {
if !m.seams.get(e).copied().unwrap_or(false) {
ids.push((body.clone(), e));
}
}
}
}
_ => {}
}
}
ids.sort();
ids.dedup();
ids
}
/// The corners of a Fillet dialog's edges where a smooth patch goes (`corner: g2`): each vertex
/// where three or more of the edges meet and every other edge there is rounded too, once.
pub(crate) fn fillet_corners(s: &Session, items: &[Sel]) -> Vec<Vec3> {
let st = crate::open_meshing::session_world(s);
let ids = fillet_edge_ids(s, items);
let mut corners: Vec<Vec3> = Vec::new();
let mut bodies: Vec<&str> = ids.iter().map(|(b, _)| b.as_str()).collect();
bodies.dedup();
for body in bodies {
let Some(b) = st.body(body) else { continue };
let m = b.mesh();
let picked: Vec<usize> = ids.iter().filter(|(x, _)| x == body).map(|(_, e)| *e).collect();
// An open edge's two ends (a closed one has no vertex to share).
let ends = |e: usize| m.edges.get(e).and_then(|p| Some((*p.first()?, *p.last()?))).filter(|(a, b)| a.dist(*b) > VERTEX_TOLERANCE);
let mut meeting: Vec<(Vec3, usize)> = Vec::new();
for (a, b) in picked.iter().filter_map(|e| ends(*e)) {
for v in [a, b] {
match meeting.iter_mut().find(|(c, _)| c.dist(v) < VERTEX_TOLERANCE) {
Some((_, n)) => *n += 1,
None => meeting.push((v, 1)),
}
}
}
for (v, n) in meeting {
let at = |e: &usize| ends(*e).is_some_and(|(a, b)| a.dist(v) < VERTEX_TOLERANCE || b.dist(v) < VERTEX_TOLERANCE);
let real = |e: &usize| !m.seams.get(*e).copied().unwrap_or(false);
if n >= 3
&& (0..m.edges.len()).filter(real).filter(at).all(|e| picked.contains(&e))
&& !corners.iter().any(|c| c.dist(v) < VERTEX_TOLERANCE)
{
corners.push(v);
}
}
}
corners
}
/// Profiles of the sketch a feature would use, when there is just one: the active sketch's, else
/// the last sketch's while no feature builds from it yet. A used profile (a cube's sketch on its
/// bottom face) is only taken when it is picked.
@@ -3381,7 +3450,16 @@ pub(crate) const CHAMFER_TYPES: [&str; 3] = ["Equal Distance", "Two Distances",
/// A new Fillet or Chamfer dialog's values.
fn fillet_kind(size: &str, chamfer: bool) -> Kind {
Kind::Fillet { radius: size.into(), chamfer, chain: true, ctype: 0, distance2: size.into(), angle: "45 deg".into(), flip: false }
Kind::Fillet {
radius: size.into(),
chamfer,
chain: true,
ctype: 0,
distance2: size.into(),
angle: "45 deg".into(),
flip: false,
setback: String::new(),
}
}
/// A length field's first text: `number` in the design's units, with the unit written out so the
@@ -3913,7 +3991,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
}
("solid.revolve", p)
}
Kind::Fillet { radius, chamfer, ctype, distance2, angle, flip, .. } => {
Kind::Fillet { radius, chamfer, ctype, distance2, angle, flip, setback, .. } => {
need(0, "edges")?;
// Faces stand for all their edges.
let st = crate::open_meshing::session_world(s);
@@ -3977,6 +4055,14 @@ 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() {
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());
}
}
("solid.fillet", p)
}
}
@@ -5274,6 +5360,23 @@ pub fn for_feature(app: &SolveApp, id: u64, marker: Option<usize>) -> Option<Dia
inp.items = edges.iter().enumerate().filter_map(|(i, p)| edge_pick(i, p.0)).collect();
d.picked_edges = inp.items.clone();
}
// Setbacks: one distance at exactly the dialog's smooth-patch corners shows in Setback;
// any other list (from the command) is kept as saved.
if let FeatureKind::Fillet { setbacks, corner, .. } = &f.kind
&& let Some(first) = setbacks.first()
{
let corners = fillet_corners(s, sels(&d, 0));
let uniform = setbacks.iter().all(|c| c.distance == first.distance);
let at_corners =
corners.len() == setbacks.len() && setbacks.iter().all(|c| corners.iter().any(|v| v.dist(c.vertex.0) < VERTEX_TOLERANCE));
match &mut d.kind {
Kind::Fillet { setback, .. } if !corner.is_rolling() && uniform && at_corners => *setback = first.distance.to_string(),
_ => {
let saved = setbacks.iter().map(|c| json!({"vertex": pt3(c.vertex.0), "distance": c.distance.to_string()}));
d.extra.insert("setbacks".into(), Value::Array(saved.collect()));
}
}
}
d.chain_applied = false;
d
}
+189 -42
View File
@@ -103,10 +103,61 @@ const FILLET_TYPES: [(&str, &str); 5] = [
("Constant radius", "constant"),
("Chord length", "chord"),
("Variable radius", "variable"),
("Curvature continuous (G2)", "curvature"),
("ui.fillet_type_curvature", "curvature"),
("Spline", "spline"),
];
/// What fills a fillet's corners where three or more rounded edges meet (labels and hover texts,
/// in the order of the `corner` words `rolling` and `g2`).
const FILLET_CORNERS: [(&str, &str); 2] =
[("ui.fillet_corner_ball", "ui.fillet_corner_ball_tip"), ("ui.fillet_corner_patch", "ui.fillet_corner_patch_tip")];
/// 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.
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| {
form_row(ui, "ui.corners", |ui| {
let mut choice = usize::from(patch);
let response = ui_kit::dropdown(ui, "fi_corner", &mut choice, &FILLET_CORNERS.map(|(label, _)| label));
if choice != usize::from(patch) {
if choice == 1 {
extra.insert("corner".into(), json!("g2"));
} else {
extra.remove("corner");
}
}
response
})
.on_hover_text(FILLET_CORNERS.map(|(_, tip)| tr(tip)).join("\n"));
if extra.get("corner").and_then(Value::as_str) == Some("g2") {
ui.scope(|ui| {
let hint = crate::i18n::format("ui.fillet_setback_hint", &[("factor", "1.5")]);
enter |= hint_row(ui, "ui.fillet_setback", setback, &hint);
})
.response
.on_hover_text(crate::i18n::format("ui.fillet_setback_tip", &[("factor", "1.5")]));
}
})
.response
.on_disabled_hover_text(tr("ui.fillet_corner_needs_constant"));
enter
}
/// The most instances a solid pattern makes in one direction (the document's own limit).
const MAX_PATTERN_INSTANCES: i64 = 1000;
@@ -133,6 +184,7 @@ fn choose_fillet_type(extra: &mut serde_json::Map<String, Value>, index: usize,
if index != 0 {
extra.remove("fit");
extra.remove("corner");
extra.remove("setbacks");
}
if index == 2 {
extra.entry("radius2").or_insert_with(|| json!("1 mm"));
@@ -341,7 +393,7 @@ pub(super) fn rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui:
}
});
}
Kind::Fillet { radius, chamfer, chain, ctype, distance2, angle, flip } => {
Kind::Fillet { radius, chamfer, chain, ctype, distance2, angle, flip, setback } => {
if *chamfer {
choice_row(ui, "Chamfer Type", "ch_type", ctype, &CHAMFER_TYPES);
}
@@ -350,21 +402,14 @@ pub(super) fn rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui:
if !*chamfer {
form_row(ui, "Type", |ui| {
let previous = rtype;
combo(ui, "fi_type", &FILLET_TYPES.map(|(label, _)| label), &mut rtype);
let response = ui_kit::dropdown(ui, "fi_type", &mut rtype, &FILLET_TYPES.map(|(label, _)| label));
if previous != rtype {
choose_fillet_type(&mut d.extra, rtype, radius);
}
});
form_row(ui, "Continuity", |ui| {
let mut continuity = usize::from(rtype == 3);
if ui_kit::segmented(ui, &mut continuity, &["G1", "G2"], Default::default()) {
rtype = if continuity == 1 { 3 } else { 0 };
choose_fillet_type(&mut d.extra, rtype, radius);
// G1 explicitly returns to rolling corners. G2 describes the edge blend,
// whereas the separate advanced Corner control describes a vertex patch.
d.extra.remove("corner");
}
});
response
})
.on_hover_text(tr("ui.fillet_type_tip"));
enter |= fillet_corner_rows(ui, &app.session, &mut d.extra, &d.inputs, rtype, setback);
}
if *chamfer && *ctype == 1 {
enter |= expr_row(ui, "Distance 2", distance2);
@@ -421,9 +466,13 @@ pub(super) fn rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui:
}
});
d.extra.insert("mid".into(), json!(mids.iter().map(|(t, r)| json!([t, r])).collect::<Vec<_>>()));
form_row(ui, "Smooth", |ui| {
extra_check(ui, &mut d.extra, "smooth");
});
// 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
@@ -467,16 +516,8 @@ pub(super) fn rows(app: &mut SolveApp, ctx: &egui::Context, d: &mut Dialog, ui:
});
}
});
let exact = d.extra.get("fit").and_then(Value::as_str).is_none_or(|fit| fit == "exact");
ui.add_enabled_ui(constant && exact, |ui| {
form_row(ui, "Corner", |ui| {
extra_choice(ui, &mut d.extra, "fi_corner", "corner", &[("Rolling", "rolling"), ("G2", "g2")]);
});
})
.response
.on_hover_text(tr("G2 corners require a constant radius and Exact fit. G2 corner construction is not supported yet."));
if !constant {
ui.label(RichText::new(tr("Fit and G2 corners require Constant radius.")).color(t.text_dim).size(t.font_small));
ui.label(RichText::new(tr("ui.fillet_fit_needs_constant")).color(t.text_dim).size(t.font_small));
}
});
}
@@ -1021,13 +1062,13 @@ mod tests {
}
#[test]
fn fillet_options_and_continuity_keep_the_existing_command_parameters() {
fn fillet_options_keep_the_existing_command_parameters() {
let mut h = Harness::new();
h.call("ui.resize", json!({"width":1400,"height":1000}));
sample(&mut h);
h.step(&json!({"start":"solid.fillet"})).unwrap();
h.frames(4);
h.step(&json!({"click":{"handle":"dialog:Continuity:G2"}})).unwrap();
choose(&mut h, "Type", "ui.fillet_type_curvature");
assert_eq!(h.app.dialog.as_ref().unwrap().extra.get("type"), Some(&json!("curvature")));
h.step(&json!({"click":{"handle":"dialog:Tangent Chain"}})).unwrap();
assert!(matches!(h.app.dialog.as_ref().unwrap().kind, Kind::Fillet { chain: false, .. }));
@@ -1083,33 +1124,136 @@ mod tests {
assert_eq!(p["knots"].as_array().unwrap().len(), 9);
assert!(p["start"].is_array());
}
assert!(scenario::handle_at("dialog:Continuity:G3").is_none());
// Type is the one place the section is chosen: no second Continuity control.
assert!(scenario::handle_at("dialog:Continuity").is_none());
}
}
#[test]
fn continuity_and_advanced_controls_map_to_edge_type_fit_and_corner() {
fn advanced_controls_map_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();
choose(&mut h, "Corner", "G2");
assert_eq!(parameters(&h)["corner"], json!("g2"));
choose(&mut h, "Corner", "Rolling");
assert!(parameters(&h).get("corner").is_none());
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_eq!(parameters(&h)["fit"], json!("reduce"));
choose(&mut h, "Radius Fit", "Taper to Fit");
assert_eq!(parameters(&h)["fit"], json!("taper"));
choose(&mut h, "Radius Fit", "Exact");
assert!(parameters(&h).get("fit").is_none());
h.step(&json!({"click":{"handle":"dialog:Continuity:G2"}})).unwrap();
assert_eq!(parameters(&h)["type"], json!("curvature"));
h.step(&json!({"click":{"handle":"dialog:Continuity:G1"}})).unwrap();
}
/// The three edges of the sample's corner at (10, 0, 5): top front, top right and upright.
fn corner_edges(h: &Harness, count: usize) -> Vec<Sel> {
[Vec3::new(5.0, 0.0, 5.0), Vec3::new(10.0, 5.0, 5.0), Vec3::new(10.0, 0.0, 2.5)]
.into_iter()
.take(count)
.map(|p| edge_sel(&h.app.session, p).unwrap())
.collect()
}
fn set_edges(h: &mut Harness, count: usize) {
let edges = corner_edges(h, count);
h.app.dialog.as_mut().unwrap().inputs.first_mut().unwrap().items = edges;
h.frames(3);
}
/// Corners shows only once three rounded edges meet at a corner; Smooth patch then writes
/// `corner: g2`, its Setback (empty for the command's 1.5 × radius) goes to that corner as
/// `setbacks`, and Ball writes neither.
#[test]
fn corners_row_follows_the_selection_and_maps_to_corner_and_setbacks() {
let mut h = fillet_harness();
for count in [1, 2] {
set_edges(&mut h, count);
assert!(scenario::handle_at("dialog:ui.corners").is_none(), "{count} edges: no corner where three rounded edges meet");
}
set_edges(&mut h, 3);
let corners = fillet_corners(&h.app.session, &h.app.dialog.as_ref().unwrap().inputs[0].items);
assert_eq!(corners.len(), 1, "{corners:?}");
assert!(corners[0].dist(Vec3::new(10.0, 0.0, 5.0)) < 1e-6, "{corners:?}");
assert!(scenario::handle_at("dialog:ui.corners").is_some(), "three rounded edges meet at (10, 0, 5)");
assert!(scenario::handle_at("dialog:ui.fillet_setback").is_none(), "Setback goes with a smooth patch");
assert!(parameters(&h).get("corner").is_none(), "Ball is the default and writes nothing");
choose(&mut h, "ui.corners", "ui.fillet_corner_patch");
let p = parameters(&h);
assert!(p.get("type").is_none() && p.get("corner").is_none());
assert_eq!(p["corner"], json!("g2"));
assert!(p.get("setbacks").is_none(), "an empty Setback leaves the command's default: {p}");
assert!(scenario::handle_at("dialog:ui.fillet_setback").is_some());
if let Some(Dialog { kind: Kind::Fillet { setback, .. }, .. }) = h.app.dialog.as_mut() {
*setback = "3 mm".into();
}
assert_eq!(parameters(&h)["setbacks"], json!([{"vertex": [10.0, 0.0, 5.0], "distance": "3 mm"}]));
choose(&mut h, "ui.corners", "ui.fillet_corner_ball");
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.
choose(&mut h, "ui.corners", "ui.fillet_corner_patch");
choose(&mut h, "Type", "Chord length");
h.step(&json!({"click":{"handle":"dialog:Continuity:G1"}})).unwrap();
assert!(parameters(&h).get("type").is_none(), "even an already highlighted G1 explicitly selects Constant radius");
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());
}
/// No visible label of the fillet dialog names a continuity by its code (G0 … G3): the words
/// are on the controls, the codes only in hover texts. Every section open, the corner row shown.
#[test]
fn fillet_labels_use_words_not_continuity_codes() {
let coded = |text: &str| ["G0", "G1", "G2", "G3"].iter().any(|code| text.contains(code));
for label in FILLET_TYPES.map(|(label, _)| label).into_iter().chain(FILLET_CORNERS.map(|(label, _)| label)) {
assert!(!coded(crate::i18n::tr_label(label)), "{label}: {}", crate::i18n::tr_label(label));
}
for tip in FILLET_CORNERS.map(|(_, tip)| tip) {
assert!(coded(crate::i18n::tr(tip)), "the hover text keeps the code for those who know it: {tip}");
}
let mut h = fillet_harness();
set_edges(&mut h, 3);
choose(&mut h, "ui.corners", "ui.fillet_corner_patch");
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());
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 == "Smooth patch"), "{labels:?}");
let shown: Vec<&String> = labels.iter().filter(|l| coded(l)).collect();
assert!(shown.is_empty(), "labels with a continuity code: {shown:?}");
}
/// A smooth-patch fillet reopens with its corner and setback, and OK writes them as saved; a
/// setback list the dialog cannot show as one distance is kept as it is.
#[test]
fn a_saved_smooth_patch_reopens_with_its_setback() {
for (setbacks, field) in [
(json!([{"vertex": [0, 0, 0], "distance": 7}]), "7"),
// Named near the vertex, as the command allows, rather than at it.
(json!([{"vertex": [0.1, 0.1, 0], "distance": 7}]), ""),
] {
let mut h = Harness::new();
// The command's own smooth-patch corner: a 20 mm box, its three edges at the origin.
h.app.run("solid.box", json!({"length": 20, "width": 20, "height": 20, "body_name": "B"})).unwrap();
let edges = json!([[10, 0, 0], [0, 10, 0], [0, 0, 10]]);
h.app.run("solid.fillet", json!({"edges": edges, "radius": 4, "corner": "g2", "setbacks": setbacks})).unwrap();
let id = h.app.session.doc.features.last().unwrap().id;
let corner_of = |h: &Harness| match &h.app.session.doc.feature(id).unwrap().kind {
FeatureKind::Fillet { corner, setbacks, .. } => (*corner, setbacks.clone()),
other => panic!("{other:?}"),
};
let saved = corner_of(&h);
assert!(!saved.0.is_rolling() && saved.1.len() == 1, "{saved:?}");
h.app.edit_feature(id);
h.frames(3);
let d = h.app.dialog.as_ref().unwrap();
assert!(matches!(&d.kind, Kind::Fillet { setback, .. } if setback == field), "{:?}", d.kind);
assert_eq!(d.extra.get("corner"), Some(&json!("g2")));
for (command, params) in apply_commands(&h.app, d).unwrap() {
crate::dialogs::roll_marker(&mut h.app, None).unwrap();
h.app.run(&command, params).unwrap();
}
assert_eq!(corner_of(&h), saved, "OK keeps the corner and its setbacks");
}
}
#[test]
@@ -1129,9 +1273,12 @@ mod tests {
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:Smooth").is_some(), "Smooth is a supported variable-radius option even before adding a middle radius");
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"
);
h.step(&json!({"widget":"Add Mid Radius"})).unwrap();
h.step(&json!({"click":{"handle":"dialog:Smooth"}})).unwrap();
h.step(&json!({"click":{"handle":"dialog:ui.fillet_smooth_radius"}})).unwrap();
let saved = parameters(&h);
assert_eq!(saved["mid"].as_array().unwrap().len(), 1);
assert_eq!(saved["smooth"], json!(true));
+8 -1
View File
@@ -91,7 +91,14 @@ pub(crate) fn fields<'a>(doc: &Document, kind: &'a mut crate::dialogs::Kind) ->
use crate::dialogs_surface::Surf;
match kind {
D::Extrude { distance, distance2, to_offset, start, wall, wall2, .. } => vec![distance, distance2, to_offset, start, wall, wall2],
D::Fillet { radius, distance2, .. } => vec![radius, distance2],
D::Fillet { radius, distance2, setback, .. } => {
// An empty setback stands for the command's default and stays empty.
let mut lengths = vec![radius, distance2];
if !setback.trim().is_empty() {
lengths.push(setback);
}
lengths
}
D::Shell { thickness } => vec![thickness],
D::PatternRect { spacing, spacing2, .. } => vec![spacing, spacing2],
D::OffsetPlane { offset } | D::Section { offset, .. } => vec![offset],