One tool window for sketch Fillet and the three sketch Chamfers (#789)
Sketch Fillet and the three sketch Chamfers share one tool window, with its own icons, labels and a scenario.
This commit is contained in:
1 parent
dbfb689f6c
commit
bdcfe3ff1d
13 files changed
+567
-70
No files matched your search
@@ -62,6 +62,16 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option<Vec3>)> {
|
||||
};
|
||||
(ss.plane.to_world(mid), dir)
|
||||
}
|
||||
// A corner picked into a sketch window ("<sketch id>:<point id>", or a bare id when it
|
||||
// was selected outside one): the value box sits on the corner itself.
|
||||
Sel::SketchPoint { id } => {
|
||||
let (sketch, point) = match id.split_once(':') {
|
||||
Some((sketch, point)) => (sketch.parse().ok()?, point),
|
||||
None => (app.session.active_sketch?, id.as_str()),
|
||||
};
|
||||
let ss = st.sketch(sketch)?;
|
||||
(ss.plane.to_world(ss.sketch.point(ss.sketch.resolve_point(point)?)?), None)
|
||||
}
|
||||
Sel::Body { name } => (st.body(name)?.mesh().bounds().center(), None),
|
||||
Sel::Plane { name } => {
|
||||
let pl = solidcraft_engine::geom::Plane::named(name).or_else(|| {
|
||||
|
||||
@@ -1191,6 +1191,39 @@ pub(crate) fn combo(ui: &mut egui::Ui, id: &str, labels: &[&str], sel: &mut usiz
|
||||
publish_row(&r.response);
|
||||
}
|
||||
|
||||
/// A mode choice as a row of icon buttons rather than a drop-down: every mode is on screen at
|
||||
/// once, each drawn as the shape it makes, with its name as the tooltip. `labels` and `icons`
|
||||
/// run in step; a mode with no icon gets the fallback square. Publishes the handles a combo
|
||||
/// does, `dialog:<row label>:<label>` per mode and `dialog:<row label>` for the row.
|
||||
pub(crate) fn icon_choice(ui: &mut egui::Ui, labels: &[&str], icons: &[&str], sel: &mut usize) {
|
||||
let t = Tokens::get();
|
||||
let row = ROW.with(|r| r.borrow().clone());
|
||||
let mut at = None;
|
||||
ui.horizontal(|ui| {
|
||||
for (i, label) in labels.iter().enumerate() {
|
||||
let on = *sel == i;
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(28.0, 24.0), egui::Sense::click());
|
||||
if on {
|
||||
ui.painter().rect_filled(r, 4.0, t.accent);
|
||||
} else if resp.hovered() {
|
||||
ui.painter().rect_filled(r, 4.0, t.hover);
|
||||
}
|
||||
let ink = if on { Color32::WHITE } else { t.icon };
|
||||
crate::icons::paint(ui.painter(), r.shrink(3.0), icons.get(i).copied().unwrap_or(""), ink, t.icon_fill, ink);
|
||||
crate::scenario::publish_handle(&format!("dialog:{row}:{label}"), r.center());
|
||||
if on {
|
||||
at = Some(r.center());
|
||||
}
|
||||
if resp.on_hover_text(tr(label)).clicked() {
|
||||
*sel = i;
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(p) = at {
|
||||
crate::scenario::publish_handle(&format!("dialog:{row}"), p);
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
/// The label of the dialog row being laid out (its widgets publish `dialog:<label>` handles).
|
||||
static ROW: std::cell::RefCell<String> = const { std::cell::RefCell::new(String::new()) };
|
||||
@@ -1278,6 +1311,8 @@ fn hint(inp: &SelInput) -> &'static str {
|
||||
"click an axis or sketch line"
|
||||
} else if a & CURVES != 0 {
|
||||
"click sketch curves"
|
||||
} else if a & selection::POINTS != 0 {
|
||||
"click sketch points"
|
||||
} else if a & BODIES != 0 {
|
||||
"click bodies"
|
||||
} else {
|
||||
@@ -2717,7 +2752,7 @@ fn mid_points(extra: &serde_json::Map<String, Value>) -> Vec<(f64, String)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
const CHAMFER_TYPES: [&str; 3] = ["Equal Distance", "Two Distances", "Distance and Angle"];
|
||||
pub(crate) const CHAMFER_TYPES: [&str; 3] = ["Equal Distance", "Two Distances", "Distance and Angle"];
|
||||
|
||||
/// A new Fillet or Chamfer dialog's values.
|
||||
fn fillet_kind(size: &str, chamfer: bool) -> Kind {
|
||||
|
||||
@@ -887,6 +887,45 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce
|
||||
1.5 * pen.s(),
|
||||
));
|
||||
}
|
||||
// The three chamfer types, for the Chamfer Type row of the corner tool window. Each
|
||||
// draws the cut it makes: equal legs with a tick on each, two unequal legs, and one leg
|
||||
// with the angle the cut leaves it at.
|
||||
"sketch_chamfer_equal" => {
|
||||
pen.line(&[(4.0, 21.0), (4.0, 12.0)], ink);
|
||||
pen.line(&[(12.0, 4.0), (21.0, 4.0)], ink);
|
||||
pen.line(&[(4.0, 12.0), (12.0, 4.0)], a);
|
||||
pen.line(&[(2.5, 9.5), (5.5, 6.5)], a);
|
||||
pen.line(&[(9.5, 2.5), (6.5, 5.5)], a);
|
||||
p.add(Shape::dashed_line(
|
||||
&[pen.at(4.0, 12.0), pen.at(4.0, 4.0), pen.at(12.0, 4.0)],
|
||||
Stroke::new(pen.w * 0.7, ink.gamma_multiply(0.5)),
|
||||
2.0 * pen.s(),
|
||||
1.5 * pen.s(),
|
||||
));
|
||||
}
|
||||
"sketch_chamfer_two" => {
|
||||
pen.line(&[(4.0, 21.0), (4.0, 10.0)], ink);
|
||||
pen.line(&[(18.0, 4.0), (21.0, 4.0)], ink);
|
||||
pen.line(&[(4.0, 10.0), (18.0, 4.0)], a);
|
||||
p.add(Shape::dashed_line(
|
||||
&[pen.at(4.0, 10.0), pen.at(4.0, 4.0), pen.at(18.0, 4.0)],
|
||||
Stroke::new(pen.w * 0.7, ink.gamma_multiply(0.5)),
|
||||
2.0 * pen.s(),
|
||||
1.5 * pen.s(),
|
||||
));
|
||||
}
|
||||
"sketch_chamfer_angle" => {
|
||||
pen.line(&[(4.0, 21.0), (4.0, 13.0)], ink);
|
||||
pen.line(&[(14.0, 4.0), (21.0, 4.0)], ink);
|
||||
pen.line(&[(4.0, 13.0), (14.0, 4.0)], a);
|
||||
pen.arc(4.0, 13.0, 6.0, -std::f32::consts::FRAC_PI_2, 0.73, a);
|
||||
p.add(Shape::dashed_line(
|
||||
&[pen.at(4.0, 13.0), pen.at(4.0, 4.0), pen.at(14.0, 4.0)],
|
||||
Stroke::new(pen.w * 0.7, ink.gamma_multiply(0.5)),
|
||||
2.0 * pen.s(),
|
||||
1.5 * pen.s(),
|
||||
));
|
||||
}
|
||||
"offset" => {
|
||||
pen.closed(&[(8.0, 9.0), (16.0, 9.0), (16.0, 15.0), (8.0, 15.0)], ink);
|
||||
pen.line(&[(8.0, 4.0), (16.0, 4.0)], a);
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"ui.centerline": "Mittellinie",
|
||||
"ui.chain": "Kette",
|
||||
"ui.chamfer": "Abschrägung",
|
||||
"ui.chamfer_type": "Fasentyp",
|
||||
"ui.change_workspace": "Arbeitsbereich wechseln",
|
||||
"ui.chord_length": "Sehnenlänge",
|
||||
"ui.circle": "Kreis",
|
||||
@@ -69,6 +70,8 @@
|
||||
"ui.construct": "Konstruktion",
|
||||
"ui.construction": "Konstruktionslinie",
|
||||
"ui.copy": "Kopieren",
|
||||
"ui.corner": "Ecke",
|
||||
"ui.corners": "Ecken",
|
||||
"ui.counterbore": "Tiefenbohrung",
|
||||
"ui.countersink": "Senkung",
|
||||
"ui.create": "Erstellen",
|
||||
|
||||
@@ -307,6 +307,7 @@
|
||||
"ui.centerline": "Centerline",
|
||||
"ui.chain": "Chain",
|
||||
"ui.chamfer": "Chamfer",
|
||||
"ui.chamfer_type": "Chamfer Type",
|
||||
"ui.change_workspace": "Change workspace",
|
||||
"ui.chord_length": "Chord Length",
|
||||
"ui.circle": "Circle",
|
||||
@@ -331,6 +332,8 @@
|
||||
"ui.construction": "Construction",
|
||||
"ui.context_menu": "Context menu",
|
||||
"ui.copy": "Copy",
|
||||
"ui.corner": "Corner",
|
||||
"ui.corners": "Corners",
|
||||
"ui.counterbore": "Counterbore",
|
||||
"ui.countersink": "Countersink",
|
||||
"ui.create": "Create",
|
||||
|
||||
@@ -307,6 +307,7 @@
|
||||
"ui.centerline": "中心線",
|
||||
"ui.chain": "チェーン",
|
||||
"ui.chamfer": "面取り",
|
||||
"ui.chamfer_type": "面取りタイプ",
|
||||
"ui.change_workspace": "ワークスペースの変更",
|
||||
"ui.chord_length": "弦長",
|
||||
"ui.circle": "円",
|
||||
@@ -331,6 +332,8 @@
|
||||
"ui.construction": "コンストラクション",
|
||||
"ui.context_menu": "コンテキスト メニュー",
|
||||
"ui.copy": "コピー",
|
||||
"ui.corner": "コーナー",
|
||||
"ui.corners": "コーナー",
|
||||
"ui.counterbore": "座ぐり",
|
||||
"ui.countersink": "皿穴",
|
||||
"ui.create": "作成",
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"ui.centerline": "중심선",
|
||||
"ui.chain": "체인",
|
||||
"ui.chamfer": "모따기",
|
||||
"ui.chamfer_type": "모따기 유형",
|
||||
"ui.change_workspace": "작업 공간 변경",
|
||||
"ui.chord_length": "현 길이",
|
||||
"ui.circle": "원",
|
||||
@@ -69,6 +70,8 @@
|
||||
"ui.construct": "구성",
|
||||
"ui.construction": "구성선",
|
||||
"ui.copy": "복사",
|
||||
"ui.corner": "코너",
|
||||
"ui.corners": "코너",
|
||||
"ui.counterbore": "카운터보어",
|
||||
"ui.countersink": "카운터싱크",
|
||||
"ui.create": "만들기",
|
||||
|
||||
@@ -135,8 +135,43 @@ fn derived_offset_context_action_releases_geometry_and_undo_restores() {
|
||||
assert_eq!(h.app.session.model.state().sketch(sid).unwrap().sketch.freeform_offsets.len(), 1);
|
||||
}
|
||||
|
||||
/// The size fields of the open Fillet or Chamfer window, as if they had been typed.
|
||||
fn set_size(app: &mut SolveApp, text: &str) {
|
||||
match app.dialog.as_mut().map(|d| &mut d.kind) {
|
||||
Some(crate::dialogs::Kind::SketchModify(crate::sketch_modify_ui::Mod::Corner { size, distance2, .. })) => {
|
||||
*size = text.to_string();
|
||||
*distance2 = text.to_string();
|
||||
}
|
||||
other => panic!("the corner window is not open: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace a dialog field's contents by typing, the way a person does.
|
||||
fn retype(h: &mut crate::scenario::Harness, handle: &str, text: &str) {
|
||||
h.step(&json!({"click":{"handle":handle}})).unwrap();
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":text})).unwrap();
|
||||
h.frames(2);
|
||||
}
|
||||
|
||||
fn apply(h: &mut crate::scenario::Harness) {
|
||||
// Lay the window out once before reading its OK handle. An error line that wraps onto a
|
||||
// second row moves the button, and a click on the handle from the frame before then
|
||||
// lands above it and commits nothing.
|
||||
h.frames(2);
|
||||
h.step(&json!({"click":{"handle":"dialog:OK"}})).unwrap();
|
||||
h.frames(2);
|
||||
}
|
||||
|
||||
/// Is there a point of the active sketch at `at`?
|
||||
fn has_point(h: &crate::scenario::Harness, at: Vec2) -> bool {
|
||||
let st = h.app.session.world_state();
|
||||
let Some(ss) = h.app.session.active_sketch.and_then(|sid| st.sketch(sid)) else { return false };
|
||||
ss.sketch.points.iter().any(|p| p.pos.dist(at) < 1e-6)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicked_origin_corner_fillets_and_chamfers_with_origin_visible() {
|
||||
fn corner_window_takes_the_corner_then_applies_the_typed_size() {
|
||||
for command in ["sketch.fillet", "sketch.chamfer.equal_distance", "sketch.chamfer.distance_angle", "sketch.chamfer.two_distance"] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.ui.show_origin = true;
|
||||
@@ -146,14 +181,29 @@ fn clicked_origin_corner_fillets_and_chamfers_with_origin_visible() {
|
||||
let sid = h.app.session.active_sketch.unwrap();
|
||||
let proj = crate::viewport::projection(&h.app, h.app.viewport.rect.unwrap());
|
||||
let cursor = proj.to_screen(Vec3::ZERO).unwrap();
|
||||
// The ordinary snap still finds the fixed origin; the corner tool must instead
|
||||
// reach the rectangle's overlapping endpoint.
|
||||
// The ordinary snap still finds the fixed origin; the window must instead reach the
|
||||
// rectangle's overlapping endpoint.
|
||||
assert_eq!(sketch_point_at(&h.app, &proj, cursor).unwrap().1.as_deref(), Some("origin"));
|
||||
let before = (h.app.session.doc.to_json(), h.app.session.undo.len());
|
||||
h.step(&json!({"start":command})).unwrap();
|
||||
h.frames(2);
|
||||
assert!(h.app.tool.is_none(), "{command} must open a window, not a click tool");
|
||||
assert!(h.app.dialog.is_some(), "{command} must open a window");
|
||||
h.step(&json!({"move":{"world":[0,0,0]}})).unwrap();
|
||||
let point = h.app.tool.as_ref().unwrap().hover.as_ref().unwrap().1.as_deref().unwrap();
|
||||
assert_ne!(point, "origin", "the actual shared endpoint must be highlighted");
|
||||
let hover = h.app.viewport.hover.clone();
|
||||
let Some(crate::viewport::Hit::SketchPoint { id, .. }) = hover else {
|
||||
panic!("{command}: no corner under the cursor: {hover:?}");
|
||||
};
|
||||
assert_ne!(id, "origin", "the actual shared endpoint must be highlighted");
|
||||
h.step(&json!({"click":{"world":[0,0,0]}})).unwrap();
|
||||
h.frames(2);
|
||||
assert_eq!(h.app.dialog.as_ref().map(|d| d.inputs[0].items.len()), Some(1), "{command}: the corner belongs in the input");
|
||||
assert_eq!((h.app.session.doc.to_json(), h.app.session.undo.len()), before, "{command} must change nothing before OK");
|
||||
// 5 mm: the size the click flow used to guess and commit before asking.
|
||||
set_size(&mut h.app, "5");
|
||||
apply(&mut h);
|
||||
assert!(h.app.dialog.is_none(), "{command}: {:?}", h.app.status);
|
||||
assert_eq!(h.app.session.undo.len(), before.1 + 1, "{command} must be one undo entry");
|
||||
let st = h.app.session.world_state();
|
||||
let ss = st.sketch(sid).unwrap();
|
||||
assert_eq!(ss.sketch.point(0), Some(Vec2::ZERO));
|
||||
@@ -167,6 +217,7 @@ fn clicked_origin_corner_fillets_and_chamfers_with_origin_visible() {
|
||||
let expected = if command == "sketch.fillet" { 600.0 - 25.0 * (1.0 - std::f64::consts::PI / 4.0) } else { 587.5 };
|
||||
assert_eq!(ss.profiles.len(), 1);
|
||||
assert!((ss.profiles[0].area - expected).abs() < 1e-6, "{command}: {}", ss.profiles[0].area);
|
||||
drop(st);
|
||||
h.app.run("edit.undo", json!({})).unwrap();
|
||||
let st = h.app.session.world_state();
|
||||
let ss = st.sketch(sid).unwrap();
|
||||
@@ -175,6 +226,158 @@ fn clicked_origin_corner_fillets_and_chamfers_with_origin_visible() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The window's reason to exist: a chamfer angle other than the 45 degrees the command used to
|
||||
/// receive hard-coded. 10 mm along the first line at 30 degrees puts the second cut at
|
||||
/// 10 * sin 30 / sin 60 mm, where 45 degrees would put it at 10 mm.
|
||||
#[test]
|
||||
fn typed_chamfer_angle_other_than_45_degrees_reaches_the_command() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,30]})).unwrap();
|
||||
h.frames(2);
|
||||
h.step(&json!({"start":"sketch.chamfer.distance_angle"})).unwrap();
|
||||
h.frames(2);
|
||||
h.step(&json!({"click":{"world":[40,0,0]}})).unwrap();
|
||||
h.frames(2);
|
||||
retype(&mut h, "dialog:Distance 1", "10");
|
||||
retype(&mut h, "dialog:Angle", "30");
|
||||
apply(&mut h);
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.status);
|
||||
let second = 10.0 / 3f64.sqrt();
|
||||
assert!(has_point(&h, Vec2::new(30.0, 0.0)), "10 mm along the first line");
|
||||
assert!(has_point(&h, Vec2::new(40.0, second)), "{second} mm along the second line, not 10 mm");
|
||||
assert!(!has_point(&h, Vec2::new(40.0, 10.0)), "45 degrees must no longer be forced");
|
||||
let st = h.app.session.world_state();
|
||||
let ss = st.sketch(h.app.session.active_sketch.unwrap()).unwrap();
|
||||
assert!((ss.profiles[0].area - (1200.0 - 0.5 * 10.0 * second)).abs() < 1e-6, "{}", ss.profiles[0].area);
|
||||
}
|
||||
|
||||
/// One window serves all four commands: the Chamfer Type row of icon buttons switches which
|
||||
/// chamfer command OK runs, with no drop-down to open.
|
||||
#[test]
|
||||
fn the_chamfer_type_icons_switch_which_command_the_window_runs() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,30]})).unwrap();
|
||||
h.frames(2);
|
||||
h.step(&json!({"start":"sketch.chamfer.equal_distance"})).unwrap();
|
||||
h.frames(2);
|
||||
h.step(&json!({"click":{"world":[40,0,0]}})).unwrap();
|
||||
h.frames(2);
|
||||
// Every mode is a button on screen, not an item behind a drop-down.
|
||||
for label in ["Equal Distance", "Two Distances", "Distance and Angle"] {
|
||||
assert!(
|
||||
crate::scenario::handle_at(&format!("dialog:Chamfer Type:{label}")).is_some(),
|
||||
"{label} must be its own button on the Chamfer Type row"
|
||||
);
|
||||
}
|
||||
h.step(&json!({"click":{"handle":"dialog:Chamfer Type:Distance and Angle"}})).unwrap();
|
||||
h.frames(2);
|
||||
retype(&mut h, "dialog:Distance 1", "10");
|
||||
retype(&mut h, "dialog:Angle", "30");
|
||||
apply(&mut h);
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.status);
|
||||
// The Distance and Angle command ran, not the Equal Distance one the window opened on.
|
||||
let second = 10.0 / 3f64.sqrt();
|
||||
assert!(has_point(&h, Vec2::new(30.0, 0.0)));
|
||||
assert!(has_point(&h, Vec2::new(40.0, second)));
|
||||
assert!(!has_point(&h, Vec2::new(40.0, 10.0)), "Equal Distance would have cut 10 mm off both legs");
|
||||
}
|
||||
|
||||
/// Two Distances forced its second distance equal to the first. Both are typed now, and Flip
|
||||
/// names the two lines in the other order so the first distance lands on the other leg.
|
||||
#[test]
|
||||
fn two_distance_chamfer_takes_both_distances_and_flip_swaps_the_legs() {
|
||||
for (flip, first, second) in [(false, Vec2::new(28.0, 0.0), Vec2::new(40.0, 4.0)), (true, Vec2::new(40.0, 12.0), Vec2::new(36.0, 0.0))] {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,30]})).unwrap();
|
||||
h.frames(2);
|
||||
h.step(&json!({"start":"sketch.chamfer.two_distance"})).unwrap();
|
||||
h.frames(2);
|
||||
h.step(&json!({"click":{"world":[40,0,0]}})).unwrap();
|
||||
h.frames(2);
|
||||
retype(&mut h, "dialog:Distance 1", "12");
|
||||
retype(&mut h, "dialog:Distance 2", "4");
|
||||
if flip {
|
||||
h.step(&json!({"click":{"handle":"dialog:Flip"}})).unwrap();
|
||||
h.frames(2);
|
||||
}
|
||||
apply(&mut h);
|
||||
assert!(h.app.dialog.is_none(), "flip {flip}: {:?}", h.app.status);
|
||||
assert!(has_point(&h, first), "flip {flip}: the first distance belongs at {first:?}");
|
||||
assert!(has_point(&h, second), "flip {flip}: the second distance belongs at {second:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_leaves_the_sketch_and_the_history_untouched() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[20,30]})).unwrap();
|
||||
h.frames(2);
|
||||
let before = (h.app.session.doc.to_json(), h.app.session.undo.len());
|
||||
h.step(&json!({"start":"sketch.fillet"})).unwrap();
|
||||
h.frames(2);
|
||||
h.step(&json!({"click":{"world":[0,0,0]}})).unwrap();
|
||||
h.frames(2);
|
||||
set_size(&mut h.app, "5");
|
||||
h.frames(2);
|
||||
h.step(&json!({"key":"Escape"})).unwrap();
|
||||
h.frames(2);
|
||||
assert!(h.app.dialog.is_none());
|
||||
assert_eq!((h.app.session.doc.to_json(), h.app.session.undo.len()), before);
|
||||
}
|
||||
|
||||
/// A size the corner cannot take is refused with a reason, and the window stays open on it.
|
||||
#[test]
|
||||
fn an_impossible_size_keeps_the_window_open_with_its_reason() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[20,30]})).unwrap();
|
||||
h.frames(2);
|
||||
let before = (h.app.session.doc.to_json(), h.app.session.undo.len());
|
||||
h.step(&json!({"start":"sketch.chamfer.equal_distance"})).unwrap();
|
||||
h.frames(2);
|
||||
h.step(&json!({"click":{"world":[0,0,0]}})).unwrap();
|
||||
h.frames(2);
|
||||
for size in ["500", "0", "-3", "not a number"] {
|
||||
set_size(&mut h.app, size);
|
||||
apply(&mut h);
|
||||
assert!(h.app.dialog.as_ref().is_some_and(|d| d.error.is_some()), "{size} must be refused with a reason");
|
||||
assert_eq!((h.app.session.doc.to_json(), h.app.session.undo.len()), before, "{size} must change nothing");
|
||||
}
|
||||
set_size(&mut h.app, "4");
|
||||
apply(&mut h);
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.status);
|
||||
assert_eq!(h.app.session.undo.len(), before.1 + 1);
|
||||
}
|
||||
|
||||
/// A fillet rounds every picked corner to one radius, as one undo entry.
|
||||
#[test]
|
||||
fn a_fillet_rounds_every_picked_corner_to_one_radius() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("sketch.create", json!({"plane":"XY"})).unwrap();
|
||||
h.app.run("sketch.rectangle.two_point", json!({"p0":[0,0],"p1":[40,30]})).unwrap();
|
||||
h.frames(2);
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"start":"sketch.fillet"})).unwrap();
|
||||
h.frames(2);
|
||||
for corner in [[40, 0, 0], [40, 30, 0], [0, 30, 0]] {
|
||||
h.step(&json!({"click":{"world":corner}})).unwrap();
|
||||
h.frames(2);
|
||||
}
|
||||
assert_eq!(h.app.dialog.as_ref().map(|d| d.inputs[0].items.len()), Some(3));
|
||||
set_size(&mut h.app, "5");
|
||||
apply(&mut h);
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.status);
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1, "one radius, one undo entry");
|
||||
let st = h.app.session.world_state();
|
||||
let ss = st.sketch(h.app.session.active_sketch.unwrap()).unwrap();
|
||||
assert_eq!(ss.sketch.curves.iter().filter(|c| matches!(c.kind, CurveKind::Arc { .. })).count(), 3);
|
||||
assert!((ss.profiles[0].area - (1200.0 - 3.0 * 25.0 * (1.0 - std::f64::consts::PI / 4.0))).abs() < 1e-6, "{}", ss.profiles[0].area);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_drawing_keeps_origin_snap_and_real_origin_corners_work() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
@@ -188,7 +391,12 @@ fn ordinary_drawing_keeps_origin_snap_and_real_origin_corners_work() {
|
||||
h.app.tool = None;
|
||||
h.app.run("sketch.line", json!({"points":["origin",[20,0],[20,20],[0,20],"origin"]})).unwrap();
|
||||
h.step(&json!({"start":"sketch.fillet"})).unwrap();
|
||||
h.frames(2);
|
||||
h.step(&json!({"click":{"world":[0,0,0]}})).unwrap();
|
||||
h.frames(2);
|
||||
set_size(&mut h.app, "5");
|
||||
apply(&mut h);
|
||||
assert!(h.app.dialog.is_none(), "{:?}", h.app.status);
|
||||
let st = h.app.session.world_state();
|
||||
let ss = st.sketch(h.app.session.active_sketch.unwrap()).unwrap();
|
||||
assert_eq!(ss.sketch.point(0), Some(Vec2::ZERO));
|
||||
|
||||
@@ -8,17 +8,30 @@
|
||||
//! right-click, then one more click for the line, with nothing on screen saying what was
|
||||
//! picked. Move joins them too, which had no toolbar placement, no tool and no dialog: its only
|
||||
//! route into the UI was the sketch context menu, and that went through the same
|
||||
//! empty-parameter fallback and reported an engine error. `sketch.fillet`, the three
|
||||
//! `sketch.chamfer.*` commands and the sketch patterns are the same gap and join this module
|
||||
//! later, each its own change.
|
||||
//! empty-parameter fallback and reported an engine error. `Mod::Corner` joins them here,
|
||||
//! serving `sketch.fillet` and the three `sketch.chamfer.*` commands together, the way
|
||||
//! `solid.fillet` and `solid.chamfer` already share one window. Before it, a click committed a
|
||||
//! size guessed at 20 percent of the shorter leg and then opened a bare parameter editor, so
|
||||
//! the design changed before any number was asked for. `sketch.chamfer.distance_angle` sent a
|
||||
//! hard-coded 45 degrees and `sketch.chamfer.two_distance` forced its second distance equal to
|
||||
//! the first, leaving both values unreachable from the UI. The sketch patterns are the same
|
||||
//! gap and join this module later, each its own change.
|
||||
//!
|
||||
//! The Chamfer Type choice is a row of icon buttons rather than a drop-down: the modes differ
|
||||
//! in shape, and a picture of each shape names it better than a line of text.
|
||||
|
||||
use egui::Pos2;
|
||||
use serde_json::{Map, Value, json};
|
||||
use solidcraft_engine::Sel;
|
||||
use solidcraft_engine::doc::expr::Kind as ValueKind;
|
||||
|
||||
use crate::SolveApp;
|
||||
use crate::dialogs::{Kind, check, field, row_label};
|
||||
use crate::dialogs::{CHAMFER_TYPES, Kind, check, field, icon_choice, row_label};
|
||||
use crate::selection::{AXES, CURVES, POINTS, SelInput};
|
||||
use crate::viewport::{Hit, Proj};
|
||||
|
||||
/// The three chamfer types, drawn as the cut each one makes, in `CHAMFER_TYPES` order.
|
||||
const CHAMFER_TYPE_ICONS: [&str; 3] = ["sketch_chamfer_equal", "sketch_chamfer_two", "sketch_chamfer_angle"];
|
||||
|
||||
/// A sketch modify tool window's values.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
@@ -39,6 +52,13 @@ pub enum Mod {
|
||||
/// turn about the centre, and whether the originals stay behind. The command already
|
||||
/// carries the copy flag, so the Create Copy row needs no engine change.
|
||||
Move { x: String, y: String, angle: String, copy: bool },
|
||||
/// `sketch.fillet` and the three `sketch.chamfer.*` commands. `chamfer` chooses between a
|
||||
/// fillet and a chamfer and never changes while the window is open; `ctype` chooses which
|
||||
/// of the three chamfer commands runs (0 equal distance, 1 two distances, 2 distance and
|
||||
/// angle). `size` is the radius or the first distance, `distance2` the second distance and
|
||||
/// `angle` the angle from the first line. `flip` names the two lines in the other order, so
|
||||
/// the first distance lands on the other leg of the corner.
|
||||
Corner { chamfer: bool, ctype: usize, size: String, distance2: String, angle: String, flip: bool },
|
||||
}
|
||||
|
||||
impl Mod {
|
||||
@@ -48,6 +68,8 @@ impl Mod {
|
||||
Mod::Offset { .. } => "OFFSET",
|
||||
Mod::Mirror => "MIRROR",
|
||||
Mod::Move { .. } => "MOVE/COPY",
|
||||
Mod::Corner { chamfer: false, .. } => "FILLET",
|
||||
Mod::Corner { .. } => "CHAMFER",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +80,8 @@ impl Mod {
|
||||
Mod::Offset { distance, .. } => Some(("Distance", ValueKind::Length, distance)),
|
||||
Mod::Move { x, .. } => Some(("X Distance", ValueKind::Length, x)),
|
||||
Mod::Mirror => None,
|
||||
Mod::Corner { chamfer: false, size, .. } => Some(("Radius", ValueKind::Length, size)),
|
||||
Mod::Corner { size, .. } => Some(("Distance", ValueKind::Length, size)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,16 +93,32 @@ fn inputs(k: &Mod) -> Vec<SelInput> {
|
||||
// AXES is the mask that takes a straight sketch curve (`selection::SelInput::accepts`).
|
||||
Mod::Mirror => vec![SelInput::new("Entities", CURVES | POINTS, true), SelInput::new("Mirror line", AXES, false)],
|
||||
Mod::Move { .. } => vec![SelInput::new("Entities", CURVES | POINTS, true), SelInput::new("Center", POINTS, false)],
|
||||
// A fillet rounds any number of corners to one radius (`points`). Each chamfer command
|
||||
// cuts one corner, so its input holds one.
|
||||
Mod::Corner { chamfer: false, .. } => vec![SelInput::new("Corners", POINTS, true)],
|
||||
Mod::Corner { .. } => vec![SelInput::new("Corner", POINTS, false)],
|
||||
}
|
||||
}
|
||||
|
||||
/// The dialog for a sketch modify command.
|
||||
pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
let corner = |chamfer: bool, ctype: usize| Mod::Corner {
|
||||
chamfer,
|
||||
ctype,
|
||||
size: "2 mm".into(),
|
||||
distance2: "2 mm".into(),
|
||||
angle: "45 deg".into(),
|
||||
flip: false,
|
||||
};
|
||||
let k = match id {
|
||||
"sketch.scale" => Mod::Scale { factor: "2".into() },
|
||||
"sketch.offset" => Mod::Offset { distance: "2 mm".into(), chain: true, flip: false },
|
||||
"sketch.mirror" => Mod::Mirror,
|
||||
"sketch.move" => Mod::Move { x: "0 mm".into(), y: "0 mm".into(), angle: "0 deg".into(), copy: false },
|
||||
"sketch.fillet" => corner(false, 0),
|
||||
"sketch.chamfer.equal_distance" => corner(true, 0),
|
||||
"sketch.chamfer.two_distance" => corner(true, 1),
|
||||
"sketch.chamfer.distance_angle" => corner(true, 2),
|
||||
_ => return None,
|
||||
};
|
||||
let i = inputs(&k);
|
||||
@@ -121,10 +161,62 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Mod) -> bool {
|
||||
check(ui, copy);
|
||||
ui.end_row();
|
||||
}
|
||||
Mod::Corner { chamfer, ctype, size, distance2, angle, flip } => {
|
||||
if *chamfer {
|
||||
row_label(ui, "Chamfer Type");
|
||||
icon_choice(ui, &CHAMFER_TYPES, &CHAMFER_TYPE_ICONS, ctype);
|
||||
ui.end_row();
|
||||
}
|
||||
row_label(ui, size_label(*chamfer, *ctype));
|
||||
enter |= field(ui, size);
|
||||
ui.end_row();
|
||||
if *chamfer && *ctype == 1 {
|
||||
row_label(ui, "Distance 2");
|
||||
enter |= field(ui, distance2);
|
||||
ui.end_row();
|
||||
}
|
||||
if *chamfer && *ctype == 2 {
|
||||
row_label(ui, "Angle");
|
||||
enter |= field(ui, angle);
|
||||
ui.end_row();
|
||||
}
|
||||
// With two equal distances there is nothing to swap.
|
||||
if *chamfer && *ctype > 0 {
|
||||
row_label(ui, "Flip");
|
||||
check(ui, flip);
|
||||
ui.end_row();
|
||||
}
|
||||
}
|
||||
}
|
||||
enter
|
||||
}
|
||||
|
||||
/// What the first value means for this corner type.
|
||||
fn size_label(chamfer: bool, ctype: usize) -> &'static str {
|
||||
if !chamfer {
|
||||
"Radius"
|
||||
} else if ctype == 0 {
|
||||
"Distance"
|
||||
} else {
|
||||
"Distance 1"
|
||||
}
|
||||
}
|
||||
|
||||
/// The corner under the cursor while the Fillet or Chamfer window is open: the hover hit and
|
||||
/// the selection it stands for.
|
||||
///
|
||||
/// Corners are resolved through `sketch_tools::corner_point` rather than the ordinary
|
||||
/// nearest-point test. A rectangle drawn from the origin leaves its own endpoint on top of the
|
||||
/// fixed origin point, and the nearest-point test returns the origin, which ends no two curves.
|
||||
/// That resolution chooses among the endpoints that actually make a corner instead.
|
||||
pub(crate) fn corner_hit(app: &SolveApp, proj: &Proj, pos: Pos2) -> Option<(Hit, Sel)> {
|
||||
let Some(Kind::SketchModify(Mod::Corner { chamfer, .. })) = app.dialog.as_ref().map(|d| &d.kind) else { return None };
|
||||
let sketch = app.session.active_sketch?;
|
||||
let (at, id) = crate::sketch_tools::corner_point(app, proj, pos, !*chamfer)?;
|
||||
let sel = Sel::SketchPoint { id: format!("{sketch}:{id}") };
|
||||
Some((Hit::SketchPoint { sketch, id, at }, sel))
|
||||
}
|
||||
|
||||
/// What input `i` holds.
|
||||
fn items(inputs: &[SelInput], i: usize) -> &[Sel] {
|
||||
inputs.get(i).map(|x| x.items.as_slice()).unwrap_or(&[])
|
||||
@@ -195,6 +287,53 @@ fn signed_length(app: &SolveApp, text: &str, flip: bool) -> Result<f64, String>
|
||||
Ok(if flip { -v } else { v })
|
||||
}
|
||||
|
||||
/// Corner point ids picked in input `i`, within the active sketch. A `POINTS` pick is stored as
|
||||
/// "<sketch id>:<point id>"; a point selected without the window carries the bare id.
|
||||
fn corner_ids(app: &SolveApp, inputs: &[SelInput], i: usize) -> Vec<String> {
|
||||
let active = app.session.active_sketch;
|
||||
items(inputs, i)
|
||||
.iter()
|
||||
.filter_map(|x| match x {
|
||||
Sel::SketchPoint { id } => match id.split_once(':') {
|
||||
Some((sketch, point)) => (sketch.parse::<u64>().ok() == active).then(|| point.to_string()),
|
||||
None => Some(id.clone()),
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A corner size evaluated against the document's parameters, which every corner command needs
|
||||
/// to be strictly positive. A bare number means the design's units for a length and degrees for
|
||||
/// an angle, as every other typed sketch value does. `value` above is the Move window's reading
|
||||
/// of a field, where an empty row means zero and a negative distance is a direction; neither is
|
||||
/// a radius or a chamfer leg, so this one is its own.
|
||||
fn corner_size(app: &SolveApp, label: &str, text: &str, kind: ValueKind) -> Result<f64, String> {
|
||||
let doc = &app.session.doc;
|
||||
let v = doc.eval(&doc.with_design_unit(text.trim(), kind), kind).map_err(|e| format!("{label}: {e}"))?;
|
||||
if !v.is_finite() || v <= 1e-9 {
|
||||
return Err(format!("{label} must be a positive number"));
|
||||
}
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// The corner as a chamfer command takes it. Unflipped it is the point itself, and the command
|
||||
/// resolves the two lines in curve order. Flipped it names those two lines in the other order,
|
||||
/// so `distance` applies to the other leg.
|
||||
fn corner_ref(app: &SolveApp, point: &str, flip: bool) -> Result<Value, String> {
|
||||
if !flip {
|
||||
return Ok(json!({ "point": point }));
|
||||
}
|
||||
let gone = || "that corner is no longer in the sketch".to_string();
|
||||
let sketch = app.session.active_sketch.ok_or("open a sketch first")?;
|
||||
let state = app.session.world_state();
|
||||
let sk = &state.sketch(sketch).ok_or_else(gone)?.sketch;
|
||||
let index = sk.resolve_point(point).ok_or_else(gone)?;
|
||||
let (a, b) = crate::sketch_tools::corner_curves(sk, index, false).ok_or("a chamfer needs a corner of exactly two lines")?;
|
||||
let id = |c: usize| sk.curves.get(c).map(|x| x.id.clone()).ok_or_else(gone);
|
||||
Ok(json!({"a": id(b)?, "b": id(a)?}))
|
||||
}
|
||||
|
||||
/// The command a sketch modify dialog makes. `sketch.scale` takes a plain evaluated number for
|
||||
/// its factor, `sketch.offset` one for its distance and `sketch.move` its distances and angle,
|
||||
/// not expression strings: none has a model parameter to carry one, unlike `sketch.fillet`'s radius.
|
||||
@@ -254,6 +393,31 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map<String
|
||||
}
|
||||
("sketch.move", p)
|
||||
}
|
||||
Mod::Corner { chamfer: false, size, .. } => {
|
||||
let corners = corner_ids(app, inputs, 0);
|
||||
if corners.is_empty() {
|
||||
return Err("select the corners to round first".into());
|
||||
}
|
||||
// The radius is checked where it is evaluated, against the document's parameters.
|
||||
("sketch.fillet", json!({"points": corners, "radius": size.trim()}))
|
||||
}
|
||||
Mod::Corner { ctype, size, distance2, angle, flip, .. } => {
|
||||
let point = corner_ids(app, inputs, 0).into_iter().next().ok_or("select the corner to cut first")?;
|
||||
let mut p = corner_ref(app, &point, *flip && *ctype > 0)?;
|
||||
p["distance"] = json!(corner_size(app, size_label(true, *ctype), size, ValueKind::Length)?);
|
||||
match ctype {
|
||||
1 => {
|
||||
p["distance2"] = json!(corner_size(app, "Distance 2", distance2, ValueKind::Length)?);
|
||||
("sketch.chamfer.two_distance", p)
|
||||
}
|
||||
2 => {
|
||||
// The command takes degrees; an evaluated angle is in radians.
|
||||
p["angle"] = json!(corner_size(app, "Angle", angle, ValueKind::Angle)?.to_degrees());
|
||||
("sketch.chamfer.distance_angle", p)
|
||||
}
|
||||
_ => ("sketch.chamfer.equal_distance", p),
|
||||
}
|
||||
}
|
||||
};
|
||||
if let Value::Object(m) = &mut params {
|
||||
for (key, value) in extra {
|
||||
|
||||
@@ -357,7 +357,6 @@ fn mode(id: &str) -> Option<Mode> {
|
||||
"sketch.text" => Mode::Points(1),
|
||||
"sketch.edit_text" => Mode::Entities(1),
|
||||
"sketch.blend_curve" => Mode::Points(2),
|
||||
"sketch.fillet" | "sketch.chamfer.equal_distance" | "sketch.chamfer.distance_angle" | "sketch.chamfer.two_distance" => Mode::Entities(1),
|
||||
"sketch.constraint.curvature" | "sketch.circle.two_tangent" => Mode::Entities(2),
|
||||
"sketch.circle.three_tangent" => Mode::Entities(3),
|
||||
"sketch.constrainer" | "sketch.constraint.polygon" => Mode::Entities(0),
|
||||
@@ -414,10 +413,6 @@ pub fn hint(id: &str) -> Option<String> {
|
||||
"sketch.text" => "Text: click where the text starts",
|
||||
"sketch.edit_text" => "Edit Text: click a letter interior or outline",
|
||||
"sketch.blend_curve" => "Blend curve: click the ends of two curves",
|
||||
"sketch.fillet" => "Fillet: click the corner of two lines",
|
||||
"sketch.chamfer.equal_distance" | "sketch.chamfer.distance_angle" | "sketch.chamfer.two_distance" => {
|
||||
"Chamfer: click the corner of two lines"
|
||||
}
|
||||
"sketch.constraint.curvature" => "Curvature: click two curves that share an end",
|
||||
"sketch.circle.two_tangent" => "2-tangent circle: click two curves (the circle goes near the second click)",
|
||||
"sketch.circle.three_tangent" => "3-tangent circle: click three curves",
|
||||
@@ -464,9 +459,30 @@ fn sketch_entity(app: &SolveApp, hits: &[Hit]) -> Option<(String, bool)> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The two editable curves that meet at sketch point `pi`, in curve order, which is the order
|
||||
/// the corner commands resolve them in from a `point` parameter. None unless exactly two meet
|
||||
/// there and each is one the command can work on: a line always, an arc only for a fillet.
|
||||
pub(crate) fn corner_curves(sk: &solidcraft_engine::sketch::Sketch, pi: usize, arcs_ok: bool) -> Option<(usize, usize)> {
|
||||
use solidcraft_engine::sketch::CurveKind;
|
||||
let curves: Vec<_> = sk
|
||||
.curves
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(ci, c)| !sk.is_linked_curve(*ci) && c.kind.ends().is_some_and(|(a, b)| a == pi || b == pi))
|
||||
.take(3)
|
||||
.collect();
|
||||
if curves.iter().any(|(_, c)| !matches!(c.kind, CurveKind::Line { .. }) && !(arcs_ok && matches!(c.kind, CurveKind::Arc { .. }))) {
|
||||
return None;
|
||||
}
|
||||
match curves[..] {
|
||||
[(a, _), (b, _)] => Some((a, b)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Corner tools accept endpoints shared by two editable curves. Pick among those endpoints
|
||||
/// directly so the origin, a circle centre or an overlapping loose point cannot hide a corner.
|
||||
pub(super) fn corner_point(app: &SolveApp, proj: &Proj, pos: Pos2, cmd: &str) -> Option<(Vec2, String)> {
|
||||
pub(crate) fn corner_point(app: &SolveApp, proj: &Proj, pos: Pos2, arcs_ok: bool) -> Option<(Vec2, String)> {
|
||||
let st = app.session.world_state();
|
||||
let ss = st.sketch(active_sketch(app)?)?;
|
||||
let sk = &ss.sketch;
|
||||
@@ -478,21 +494,7 @@ pub(super) fn corner_point(app: &SolveApp, proj: &Proj, pos: Pos2, cmd: &str) ->
|
||||
if distance >= 8.0 {
|
||||
return None;
|
||||
}
|
||||
let curves: Vec<_> = sk
|
||||
.curves
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(ci, c)| !sk.is_linked_curve(*ci) && c.kind.ends().is_some_and(|(a, b)| a == pi || b == pi))
|
||||
.take(3)
|
||||
.collect();
|
||||
if curves.len() != 2
|
||||
|| curves.iter().any(|(_, c)| {
|
||||
!matches!(c.kind, solidcraft_engine::sketch::CurveKind::Line { .. })
|
||||
&& (cmd != "sketch.fillet" || !matches!(c.kind, solidcraft_engine::sketch::CurveKind::Arc { .. }))
|
||||
})
|
||||
{
|
||||
return None;
|
||||
}
|
||||
corner_curves(sk, pi, arcs_ok)?;
|
||||
Some((distance, point.pos, point.id.clone()))
|
||||
})
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
@@ -517,21 +519,6 @@ fn curve_len(app: &SolveApp, id: &str) -> f64 {
|
||||
ss.sketch.curve_index(id).map(|c| ss.sketch.polyline(c).windows(2).map(|w| w[0].dist(w[1])).sum::<f64>()).unwrap_or(10.0)
|
||||
}
|
||||
|
||||
/// Lines meeting at a sketch point.
|
||||
fn corner_len(app: &SolveApp, point: &str) -> f64 {
|
||||
let st = app.session.world_state();
|
||||
let Some(ss) = active_sketch(app).and_then(|s| st.sketch(s)) else { return 10.0 };
|
||||
let sk = &ss.sketch;
|
||||
let Some(q) = sk.resolve_point(point) else { return 10.0 };
|
||||
sk.curves
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| c.kind.uses(q) && matches!(c.kind, solidcraft_engine::sketch::CurveKind::Line { .. }))
|
||||
.map(|(i, _)| sk.polyline(i).windows(2).map(|w| w[0].dist(w[1])).sum::<f64>())
|
||||
.fold(f64::INFINITY, f64::min)
|
||||
.min(1e6)
|
||||
}
|
||||
|
||||
fn round_nice(x: f64) -> f64 {
|
||||
if !(x.is_finite() && x > 0.0) {
|
||||
return 1.0;
|
||||
@@ -700,12 +687,6 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) {
|
||||
place_point(app, tool, p);
|
||||
}
|
||||
Mode::Entities(n) => {
|
||||
if cmd == "sketch.fillet" || cmd.starts_with("sketch.chamfer.") {
|
||||
if let Some((_, id)) = corner_point(app, proj, pos, cmd) {
|
||||
run_corner(app, cmd, &id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if cmd == "sketch.edit_text" {
|
||||
let selection = hits.first().and_then(|hit| match hit {
|
||||
Hit::SketchCurve { sketch, id, .. } if Some(*sketch) == active_sketch(app) => {
|
||||
@@ -863,21 +844,6 @@ fn run_points(app: &mut SolveApp, t: &Tool) {
|
||||
let _ = app.run(t.cmd, params);
|
||||
}
|
||||
|
||||
fn run_corner(app: &mut SolveApp, cmd: &str, point: &str) {
|
||||
let size = round_nice(corner_len(app, point) * 0.2);
|
||||
let params = match cmd {
|
||||
"sketch.fillet" => json!({"point": point, "radius": size}),
|
||||
"sketch.chamfer.distance_angle" => json!({"point": point, "distance": size, "angle": 45}),
|
||||
"sketch.chamfer.two_distance" => json!({"point": point, "distance": size, "distance2": size}),
|
||||
_ => json!({"point": point, "distance": size}),
|
||||
};
|
||||
if let Ok(v) = app.run(cmd, params)
|
||||
&& let Some(p) = v["param"].as_str()
|
||||
{
|
||||
app.dialog = Some(crate::dialogs::Dialog::edit_param(&app.session, p));
|
||||
}
|
||||
}
|
||||
|
||||
fn run_entities(app: &mut SolveApp, t: &Tool) {
|
||||
let k = &t.picks;
|
||||
let params = match t.cmd {
|
||||
|
||||
@@ -125,8 +125,6 @@ pub fn on_hover(app: &mut SolveApp, proj: &Proj, pos: Pos2) {
|
||||
crate::sketch3d::on_hover(app, proj, pos);
|
||||
let h = if let Some(point) = app.tool.as_ref().and_then(|tool| crate::sketch_tools::periodic_start_snap(app, tool, proj, pos)) {
|
||||
Some((point, Some("Close smooth loop".into())))
|
||||
} else if let Some(tool) = app.tool.as_ref().filter(|t| t.cmd == "sketch.fillet" || t.cmd.starts_with("sketch.chamfer.")) {
|
||||
crate::sketch_tools::corner_point(app, proj, pos, tool.cmd).map(|(at, id)| (at, Some(id)))
|
||||
} else {
|
||||
sketch_point_at(app, proj, pos)
|
||||
};
|
||||
|
||||
@@ -2014,7 +2014,16 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
|| app.viewport.orbit_snap.is_some()
|
||||
|| app.cam_anim.is_some();
|
||||
let hits = hover.filter(|_| inside && app.viewport.boxsel.is_none() && !navigating).map(|p| pick_cached(app, &proj, p)).unwrap_or_default();
|
||||
let cand = if app.tool.is_some() { hits.first().cloned().map(|h| (h, None)) } else { candidate(app, &hits).map(|(h, s)| (h, Some(s))) };
|
||||
// The Fillet and Chamfer window resolves its own corners: the nearest sketch point to a
|
||||
// corner drawn from the origin is the fixed origin, which ends no two curves.
|
||||
let corner = hover.filter(|_| inside && !navigating).and_then(|p| crate::sketch_modify_ui::corner_hit(app, &proj, p));
|
||||
let cand = if app.tool.is_some() {
|
||||
hits.first().cloned().map(|h| (h, None))
|
||||
} else if let Some((hit, sel)) = corner {
|
||||
Some((hit, Some(sel)))
|
||||
} else {
|
||||
candidate(app, &hits).map(|(h, s)| (h, Some(s)))
|
||||
};
|
||||
app.viewport.hover = cand.as_ref().map(|c| c.0.clone());
|
||||
// Select Other: a press held still opens the list of everything under it; while the list is
|
||||
// open its row under the pointer is what lights up.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
[
|
||||
{"note": "Sketch Fillet and the three Sketch Chamfer commands share one tool window. A click used to commit a size guessed at 20 percent of the shorter leg and then open a bare parameter editor. The chamfer angle was hard-coded to 45 degrees and the second distance was forced equal to the first, so neither was reachable."},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}}},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 30]}}},
|
||||
{"expect": {"sketch_curves": {"own": 4}, "errors": 0}},
|
||||
|
||||
{"note": "Equal Distance opens the window and commits nothing until values are given. The Chamfer Type row of icon buttons then switches which of the three chamfer commands OK runs."},
|
||||
{"start": "sketch.chamfer.equal_distance"},
|
||||
{"expect": {"dialog": "Corner { chamfer: true, ctype: 0", "tool": null, "sketch_curves": {"own": 4}}},
|
||||
{"click": {"world": [40, 30, 0]}},
|
||||
{"expect": {"sketch_curves": {"own": 4}}},
|
||||
{"click": {"handle": "dialog:Chamfer Type:Distance and Angle"}},
|
||||
{"expect": {"dialog": "Corner { chamfer: true, ctype: 2"}},
|
||||
|
||||
{"click": {"handle": "dialog:Distance 1"}},
|
||||
{"key": "A", "cmd": true},
|
||||
{"text": "8"},
|
||||
{"click": {"handle": "dialog:Angle"}},
|
||||
{"key": "A", "cmd": true},
|
||||
{"text": "30"},
|
||||
{"widget": "OK"},
|
||||
{"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 5}}},
|
||||
|
||||
{"note": "The typed 8 reached the command: the cut starts 8 mm down the right edge."},
|
||||
{"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 4, "type"], "value": "line"}}},
|
||||
{"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 4, "start_at", 1], "value": 22}}},
|
||||
{"note": "The typed 30 degrees reached it too: 8 * sin 30 / sin 60 = 4.6188 mm off the top edge. A hard-coded 45 degrees would cut 8 mm and leave 32."},
|
||||
{"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 4, "end_at", 0], "value": 35.381197846482}}},
|
||||
|
||||
{"note": "Escape leaves the sketch and the history exactly as they were."},
|
||||
{"start": "sketch.chamfer.equal_distance"},
|
||||
{"expect": {"dialog": "Corner { chamfer: true, ctype: 0"}},
|
||||
{"click": {"world": [0, 30, 0]}},
|
||||
{"click": {"handle": "dialog:Distance"}},
|
||||
{"key": "A", "cmd": true},
|
||||
{"text": "5"},
|
||||
{"key": "Escape"},
|
||||
{"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 5}}},
|
||||
|
||||
{"note": "Fillet: the same window with a radius, on a second sketch."},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.finish", "params": {}}},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XZ"}}},
|
||||
{"call": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 30]}}},
|
||||
{"start": "sketch.fillet"},
|
||||
{"expect": {"dialog": "Corner { chamfer: false", "tool": null}},
|
||||
{"click": {"sketch": [40, 0]}},
|
||||
{"expect": {"sketch_curves": {"own": 4}}},
|
||||
{"click": {"handle": "dialog:Radius"}},
|
||||
{"key": "A", "cmd": true},
|
||||
{"text": "6"},
|
||||
{"key": "Enter"},
|
||||
{"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 5}}},
|
||||
{"note": "The typed 6 reached the command, which the guessed size never let anyone choose."},
|
||||
{"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 4, "type"], "value": "arc"}}},
|
||||
{"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 4, "radius"], "value": 6}}}
|
||||
]
|
||||
Reference in new issue
Block a user