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:
William Dunn authored and GitHub committed 2026-10-13 14:32:13 -04:00
1 parent dbfb689f6c
commit bdcfe3ff1d
13 files changed
+567 -70

No files matched your search

+10
View File
@@ -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(|| {
+36 -1
View File
@@ -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 {
+39
View File
@@ -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);
+3
View File
@@ -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",
+3
View File
@@ -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",
+3
View File
@@ -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": "作成",
+3
View File
@@ -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": "만들기",
+213 -5
View File
@@ -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));
+168 -4
View File
@@ -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 {
+23 -57
View File
@@ -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 {
-2
View File
@@ -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)
};
+10 -1
View File
@@ -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}}}
]