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)
|
(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::Body { name } => (st.body(name)?.mesh().bounds().center(), None),
|
||||||
Sel::Plane { name } => {
|
Sel::Plane { name } => {
|
||||||
let pl = solidcraft_engine::geom::Plane::named(name).or_else(|| {
|
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);
|
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! {
|
thread_local! {
|
||||||
/// The label of the dialog row being laid out (its widgets publish `dialog:<label>` handles).
|
/// 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()) };
|
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"
|
"click an axis or sketch line"
|
||||||
} else if a & CURVES != 0 {
|
} else if a & CURVES != 0 {
|
||||||
"click sketch curves"
|
"click sketch curves"
|
||||||
|
} else if a & selection::POINTS != 0 {
|
||||||
|
"click sketch points"
|
||||||
} else if a & BODIES != 0 {
|
} else if a & BODIES != 0 {
|
||||||
"click bodies"
|
"click bodies"
|
||||||
} else {
|
} else {
|
||||||
@@ -2717,7 +2752,7 @@ fn mid_points(extra: &serde_json::Map<String, Value>) -> Vec<(f64, String)> {
|
|||||||
.collect()
|
.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.
|
/// A new Fillet or Chamfer dialog's values.
|
||||||
fn fillet_kind(size: &str, chamfer: bool) -> Kind {
|
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(),
|
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" => {
|
"offset" => {
|
||||||
pen.closed(&[(8.0, 9.0), (16.0, 9.0), (16.0, 15.0), (8.0, 15.0)], ink);
|
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);
|
pen.line(&[(8.0, 4.0), (16.0, 4.0)], a);
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
"ui.centerline": "Mittellinie",
|
"ui.centerline": "Mittellinie",
|
||||||
"ui.chain": "Kette",
|
"ui.chain": "Kette",
|
||||||
"ui.chamfer": "Abschrägung",
|
"ui.chamfer": "Abschrägung",
|
||||||
|
"ui.chamfer_type": "Fasentyp",
|
||||||
"ui.change_workspace": "Arbeitsbereich wechseln",
|
"ui.change_workspace": "Arbeitsbereich wechseln",
|
||||||
"ui.chord_length": "Sehnenlänge",
|
"ui.chord_length": "Sehnenlänge",
|
||||||
"ui.circle": "Kreis",
|
"ui.circle": "Kreis",
|
||||||
@@ -69,6 +70,8 @@
|
|||||||
"ui.construct": "Konstruktion",
|
"ui.construct": "Konstruktion",
|
||||||
"ui.construction": "Konstruktionslinie",
|
"ui.construction": "Konstruktionslinie",
|
||||||
"ui.copy": "Kopieren",
|
"ui.copy": "Kopieren",
|
||||||
|
"ui.corner": "Ecke",
|
||||||
|
"ui.corners": "Ecken",
|
||||||
"ui.counterbore": "Tiefenbohrung",
|
"ui.counterbore": "Tiefenbohrung",
|
||||||
"ui.countersink": "Senkung",
|
"ui.countersink": "Senkung",
|
||||||
"ui.create": "Erstellen",
|
"ui.create": "Erstellen",
|
||||||
|
|||||||
@@ -307,6 +307,7 @@
|
|||||||
"ui.centerline": "Centerline",
|
"ui.centerline": "Centerline",
|
||||||
"ui.chain": "Chain",
|
"ui.chain": "Chain",
|
||||||
"ui.chamfer": "Chamfer",
|
"ui.chamfer": "Chamfer",
|
||||||
|
"ui.chamfer_type": "Chamfer Type",
|
||||||
"ui.change_workspace": "Change workspace",
|
"ui.change_workspace": "Change workspace",
|
||||||
"ui.chord_length": "Chord Length",
|
"ui.chord_length": "Chord Length",
|
||||||
"ui.circle": "Circle",
|
"ui.circle": "Circle",
|
||||||
@@ -331,6 +332,8 @@
|
|||||||
"ui.construction": "Construction",
|
"ui.construction": "Construction",
|
||||||
"ui.context_menu": "Context menu",
|
"ui.context_menu": "Context menu",
|
||||||
"ui.copy": "Copy",
|
"ui.copy": "Copy",
|
||||||
|
"ui.corner": "Corner",
|
||||||
|
"ui.corners": "Corners",
|
||||||
"ui.counterbore": "Counterbore",
|
"ui.counterbore": "Counterbore",
|
||||||
"ui.countersink": "Countersink",
|
"ui.countersink": "Countersink",
|
||||||
"ui.create": "Create",
|
"ui.create": "Create",
|
||||||
|
|||||||
@@ -307,6 +307,7 @@
|
|||||||
"ui.centerline": "中心線",
|
"ui.centerline": "中心線",
|
||||||
"ui.chain": "チェーン",
|
"ui.chain": "チェーン",
|
||||||
"ui.chamfer": "面取り",
|
"ui.chamfer": "面取り",
|
||||||
|
"ui.chamfer_type": "面取りタイプ",
|
||||||
"ui.change_workspace": "ワークスペースの変更",
|
"ui.change_workspace": "ワークスペースの変更",
|
||||||
"ui.chord_length": "弦長",
|
"ui.chord_length": "弦長",
|
||||||
"ui.circle": "円",
|
"ui.circle": "円",
|
||||||
@@ -331,6 +332,8 @@
|
|||||||
"ui.construction": "コンストラクション",
|
"ui.construction": "コンストラクション",
|
||||||
"ui.context_menu": "コンテキスト メニュー",
|
"ui.context_menu": "コンテキスト メニュー",
|
||||||
"ui.copy": "コピー",
|
"ui.copy": "コピー",
|
||||||
|
"ui.corner": "コーナー",
|
||||||
|
"ui.corners": "コーナー",
|
||||||
"ui.counterbore": "座ぐり",
|
"ui.counterbore": "座ぐり",
|
||||||
"ui.countersink": "皿穴",
|
"ui.countersink": "皿穴",
|
||||||
"ui.create": "作成",
|
"ui.create": "作成",
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
"ui.centerline": "중심선",
|
"ui.centerline": "중심선",
|
||||||
"ui.chain": "체인",
|
"ui.chain": "체인",
|
||||||
"ui.chamfer": "모따기",
|
"ui.chamfer": "모따기",
|
||||||
|
"ui.chamfer_type": "모따기 유형",
|
||||||
"ui.change_workspace": "작업 공간 변경",
|
"ui.change_workspace": "작업 공간 변경",
|
||||||
"ui.chord_length": "현 길이",
|
"ui.chord_length": "현 길이",
|
||||||
"ui.circle": "원",
|
"ui.circle": "원",
|
||||||
@@ -69,6 +70,8 @@
|
|||||||
"ui.construct": "구성",
|
"ui.construct": "구성",
|
||||||
"ui.construction": "구성선",
|
"ui.construction": "구성선",
|
||||||
"ui.copy": "복사",
|
"ui.copy": "복사",
|
||||||
|
"ui.corner": "코너",
|
||||||
|
"ui.corners": "코너",
|
||||||
"ui.counterbore": "카운터보어",
|
"ui.counterbore": "카운터보어",
|
||||||
"ui.countersink": "카운터싱크",
|
"ui.countersink": "카운터싱크",
|
||||||
"ui.create": "만들기",
|
"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);
|
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]
|
#[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"] {
|
for command in ["sketch.fillet", "sketch.chamfer.equal_distance", "sketch.chamfer.distance_angle", "sketch.chamfer.two_distance"] {
|
||||||
let mut h = crate::scenario::Harness::new();
|
let mut h = crate::scenario::Harness::new();
|
||||||
h.app.ui.show_origin = true;
|
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 sid = h.app.session.active_sketch.unwrap();
|
||||||
let proj = crate::viewport::projection(&h.app, h.app.viewport.rect.unwrap());
|
let proj = crate::viewport::projection(&h.app, h.app.viewport.rect.unwrap());
|
||||||
let cursor = proj.to_screen(Vec3::ZERO).unwrap();
|
let cursor = proj.to_screen(Vec3::ZERO).unwrap();
|
||||||
// The ordinary snap still finds the fixed origin; the corner tool must instead
|
// The ordinary snap still finds the fixed origin; the window must instead reach the
|
||||||
// reach the rectangle's overlapping endpoint.
|
// rectangle's overlapping endpoint.
|
||||||
assert_eq!(sketch_point_at(&h.app, &proj, cursor).unwrap().1.as_deref(), Some("origin"));
|
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.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();
|
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();
|
let hover = h.app.viewport.hover.clone();
|
||||||
assert_ne!(point, "origin", "the actual shared endpoint must be highlighted");
|
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.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 st = h.app.session.world_state();
|
||||||
let ss = st.sketch(sid).unwrap();
|
let ss = st.sketch(sid).unwrap();
|
||||||
assert_eq!(ss.sketch.point(0), Some(Vec2::ZERO));
|
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 };
|
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_eq!(ss.profiles.len(), 1);
|
||||||
assert!((ss.profiles[0].area - expected).abs() < 1e-6, "{command}: {}", ss.profiles[0].area);
|
assert!((ss.profiles[0].area - expected).abs() < 1e-6, "{command}: {}", ss.profiles[0].area);
|
||||||
|
drop(st);
|
||||||
h.app.run("edit.undo", json!({})).unwrap();
|
h.app.run("edit.undo", json!({})).unwrap();
|
||||||
let st = h.app.session.world_state();
|
let st = h.app.session.world_state();
|
||||||
let ss = st.sketch(sid).unwrap();
|
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]
|
#[test]
|
||||||
fn ordinary_drawing_keeps_origin_snap_and_real_origin_corners_work() {
|
fn ordinary_drawing_keeps_origin_snap_and_real_origin_corners_work() {
|
||||||
let mut h = crate::scenario::Harness::new();
|
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.tool = None;
|
||||||
h.app.run("sketch.line", json!({"points":["origin",[20,0],[20,20],[0,20],"origin"]})).unwrap();
|
h.app.run("sketch.line", json!({"points":["origin",[20,0],[20,20],[0,20],"origin"]})).unwrap();
|
||||||
h.step(&json!({"start":"sketch.fillet"})).unwrap();
|
h.step(&json!({"start":"sketch.fillet"})).unwrap();
|
||||||
|
h.frames(2);
|
||||||
h.step(&json!({"click":{"world":[0,0,0]}})).unwrap();
|
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 st = h.app.session.world_state();
|
||||||
let ss = st.sketch(h.app.session.active_sketch.unwrap()).unwrap();
|
let ss = st.sketch(h.app.session.active_sketch.unwrap()).unwrap();
|
||||||
assert_eq!(ss.sketch.point(0), Some(Vec2::ZERO));
|
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
|
//! 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
|
//! 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
|
//! 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
|
//! empty-parameter fallback and reported an engine error. `Mod::Corner` joins them here,
|
||||||
//! `sketch.chamfer.*` commands and the sketch patterns are the same gap and join this module
|
//! serving `sketch.fillet` and the three `sketch.chamfer.*` commands together, the way
|
||||||
//! later, each its own change.
|
//! `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 serde_json::{Map, Value, json};
|
||||||
use solidcraft_engine::Sel;
|
use solidcraft_engine::Sel;
|
||||||
use solidcraft_engine::doc::expr::Kind as ValueKind;
|
use solidcraft_engine::doc::expr::Kind as ValueKind;
|
||||||
|
|
||||||
use crate::SolveApp;
|
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::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.
|
/// A sketch modify tool window's values.
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
@@ -39,6 +52,13 @@ pub enum Mod {
|
|||||||
/// turn about the centre, and whether the originals stay behind. The command already
|
/// 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.
|
/// carries the copy flag, so the Create Copy row needs no engine change.
|
||||||
Move { x: String, y: String, angle: String, copy: bool },
|
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 {
|
impl Mod {
|
||||||
@@ -48,6 +68,8 @@ impl Mod {
|
|||||||
Mod::Offset { .. } => "OFFSET",
|
Mod::Offset { .. } => "OFFSET",
|
||||||
Mod::Mirror => "MIRROR",
|
Mod::Mirror => "MIRROR",
|
||||||
Mod::Move { .. } => "MOVE/COPY",
|
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::Offset { distance, .. } => Some(("Distance", ValueKind::Length, distance)),
|
||||||
Mod::Move { x, .. } => Some(("X Distance", ValueKind::Length, x)),
|
Mod::Move { x, .. } => Some(("X Distance", ValueKind::Length, x)),
|
||||||
Mod::Mirror => None,
|
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`).
|
// 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::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)],
|
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.
|
/// The dialog for a sketch modify command.
|
||||||
pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
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 {
|
let k = match id {
|
||||||
"sketch.scale" => Mod::Scale { factor: "2".into() },
|
"sketch.scale" => Mod::Scale { factor: "2".into() },
|
||||||
"sketch.offset" => Mod::Offset { distance: "2 mm".into(), chain: true, flip: false },
|
"sketch.offset" => Mod::Offset { distance: "2 mm".into(), chain: true, flip: false },
|
||||||
"sketch.mirror" => Mod::Mirror,
|
"sketch.mirror" => Mod::Mirror,
|
||||||
"sketch.move" => Mod::Move { x: "0 mm".into(), y: "0 mm".into(), angle: "0 deg".into(), copy: false },
|
"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,
|
_ => return None,
|
||||||
};
|
};
|
||||||
let i = inputs(&k);
|
let i = inputs(&k);
|
||||||
@@ -121,10 +161,62 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Mod) -> bool {
|
|||||||
check(ui, copy);
|
check(ui, copy);
|
||||||
ui.end_row();
|
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
|
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.
|
/// What input `i` holds.
|
||||||
fn items(inputs: &[SelInput], i: usize) -> &[Sel] {
|
fn items(inputs: &[SelInput], i: usize) -> &[Sel] {
|
||||||
inputs.get(i).map(|x| x.items.as_slice()).unwrap_or(&[])
|
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 })
|
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
|
/// 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,
|
/// 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.
|
/// 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)
|
("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 {
|
if let Value::Object(m) = &mut params {
|
||||||
for (key, value) in extra {
|
for (key, value) in extra {
|
||||||
|
|||||||
@@ -357,7 +357,6 @@ fn mode(id: &str) -> Option<Mode> {
|
|||||||
"sketch.text" => Mode::Points(1),
|
"sketch.text" => Mode::Points(1),
|
||||||
"sketch.edit_text" => Mode::Entities(1),
|
"sketch.edit_text" => Mode::Entities(1),
|
||||||
"sketch.blend_curve" => Mode::Points(2),
|
"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.constraint.curvature" | "sketch.circle.two_tangent" => Mode::Entities(2),
|
||||||
"sketch.circle.three_tangent" => Mode::Entities(3),
|
"sketch.circle.three_tangent" => Mode::Entities(3),
|
||||||
"sketch.constrainer" | "sketch.constraint.polygon" => Mode::Entities(0),
|
"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.text" => "Text: click where the text starts",
|
||||||
"sketch.edit_text" => "Edit Text: click a letter interior or outline",
|
"sketch.edit_text" => "Edit Text: click a letter interior or outline",
|
||||||
"sketch.blend_curve" => "Blend curve: click the ends of two curves",
|
"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.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.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",
|
"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
|
/// 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.
|
/// 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 st = app.session.world_state();
|
||||||
let ss = st.sketch(active_sketch(app)?)?;
|
let ss = st.sketch(active_sketch(app)?)?;
|
||||||
let sk = &ss.sketch;
|
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 {
|
if distance >= 8.0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let curves: Vec<_> = sk
|
corner_curves(sk, pi, arcs_ok)?;
|
||||||
.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;
|
|
||||||
}
|
|
||||||
Some((distance, point.pos, point.id.clone()))
|
Some((distance, point.pos, point.id.clone()))
|
||||||
})
|
})
|
||||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
.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)
|
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 {
|
fn round_nice(x: f64) -> f64 {
|
||||||
if !(x.is_finite() && x > 0.0) {
|
if !(x.is_finite() && x > 0.0) {
|
||||||
return 1.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);
|
place_point(app, tool, p);
|
||||||
}
|
}
|
||||||
Mode::Entities(n) => {
|
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" {
|
if cmd == "sketch.edit_text" {
|
||||||
let selection = hits.first().and_then(|hit| match hit {
|
let selection = hits.first().and_then(|hit| match hit {
|
||||||
Hit::SketchCurve { sketch, id, .. } if Some(*sketch) == active_sketch(app) => {
|
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);
|
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) {
|
fn run_entities(app: &mut SolveApp, t: &Tool) {
|
||||||
let k = &t.picks;
|
let k = &t.picks;
|
||||||
let params = match t.cmd {
|
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);
|
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)) {
|
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())))
|
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 {
|
} else {
|
||||||
sketch_point_at(app, proj, pos)
|
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.viewport.orbit_snap.is_some()
|
||||||
|| app.cam_anim.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 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());
|
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
|
// 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.
|
// 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