From beca7eb12602d604c2e8c8b945d01750e47fcfd8 Mon Sep 17 00:00:00 2001 From: William Dunn Date: Tue, 13 Oct 2026 12:46:24 -0400 Subject: [PATCH] A tool window for sketch Offset, with a typed Distance, Flip and Chain (#731) Sketch Offset gets its own tool window with a typed Distance, Flip and Chain, as a variant of the sketch modify window. --- crates/ui-egui/src/canvas.rs | 40 ++++++++- crates/ui-egui/src/dialogs.rs | 9 +- crates/ui-egui/src/locales/de.json | 3 + crates/ui-egui/src/locales/en.json | 3 + crates/ui-egui/src/locales/ja.json | 3 + crates/ui-egui/src/locales/ko.json | 3 + crates/ui-egui/src/sketch_corner_ui_tests.rs | 52 ++++++++++-- crates/ui-egui/src/sketch_modify_ui.rs | 63 ++++++++++++-- crates/ui-egui/src/sketch_tools.rs | 50 ----------- .../tests/scenarios/sketch_offset_window.json | 82 +++++++++++++++++++ 10 files changed, 239 insertions(+), 69 deletions(-) create mode 100644 crates/ui-egui/tests/scenarios/sketch_offset_window.json diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 21f8bc4a..01fe4547 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -8,7 +8,7 @@ use egui::{Color32, Pos2, Stroke, vec2}; use solidcraft_engine::Sel; use solidcraft_engine::doc::expr::Kind as ValueKind; -use solidcraft_engine::geom::{Mesh, Vec3}; +use solidcraft_engine::geom::{Mesh, Vec2, Vec3}; use crate::SolveApp; use crate::dialogs::{Dialog, Kind}; @@ -50,7 +50,17 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option)> { Sel::SketchCurve { id } => { let ss = st.sketches.iter().rev().find(|ss| ss.sketch.curve_index(id).is_some())?; let pts = ss.sketch.polyline(ss.sketch.curve_index(id)?); - (ss.plane.to_world(*pts.get(pts.len() / 2)?), None) + let (mid, tangent) = poly_mid(&pts)?; + // An offset's drag arrow: the in-plane direction the distance grows along. The + // command offsets a picked curve to its left, and the window's Flip row reverses it. + let dir = match &d.kind { + Kind::SketchModify(crate::sketch_modify_ui::Mod::Offset { flip, .. }) => { + let n = if *flip { -tangent.perp() } else { tangent.perp() }; + Some(ss.plane.to_world(mid + n) - ss.plane.to_world(mid)) + } + _ => None, + }; + (ss.plane.to_world(mid), dir) } Sel::Body { name } => (st.body(name)?.mesh().bounds().center(), None), Sel::Plane { name } => { @@ -75,10 +85,36 @@ pub fn anchor(app: &SolveApp, d: &Dialog) -> Option<(Vec3, Option)> { Kind::Sheet(_) => (at, crate::dialogs_sheet::arrow(app, d).or(normal)), Kind::Plastic(_) => (at, normal), Kind::PatternRect { .. } => (at, d.inputs.get(1).and_then(|i| i.items.first()).and_then(|x| crate::dialogs::axis_of(app, x)).map(|a| a.1)), + // Already computed above, per picked curve; keep it rather than the default below. + Kind::SketchModify(_) => (at, normal), _ => (at, None), }) } +/// The point halfway along a polyline, with the unit tangent there. Indexing the middle sample +/// instead would land on a two-point line's far end, putting the value box and its arrow on a +/// corner. None for a polyline with no length. +fn poly_mid(poly: &[Vec2]) -> Option<(Vec2, Vec2)> { + let total: f64 = poly.windows(2).map(|w| w[0].dist(w[1])).sum(); + if !total.is_finite() || total <= 0.0 { + return None; + } + let half = total / 2.0; + let mut walked = 0.0; + let mut last = None; + for w in poly.windows(2) { + let (a, b) = (w[0], w[1]); + let Some(dir) = (b - a).normalized() else { continue }; + let len = a.dist(b); + if walked + len >= half { + return Some((a + dir * (half - walked), dir)); + } + walked += len; + last = Some((b, dir)); + } + last +} + fn profile_plane(app: &SolveApp, sketch: u64) -> Option { let component = app.session.doc.feature(sketch)?.component; let state = app.session.model.state(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index fa9953d1..168d6516 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -248,7 +248,7 @@ pub enum Kind { /// Edit Text: a sketch text's content and placement (`sketch_text`). Text(crate::sketch_text::Tx), TextMove(crate::sketch_text_move::TextMove), - /// Sketch-space modify tools: scale, and later offset, fillet, chamfer, the patterns, + /// Sketch-space modify tools: scale and offset, and later fillet, chamfer, the patterns, /// mirror and move (`sketch_modify_ui`). SketchModify(crate::sketch_modify_ui::Mod), } @@ -918,6 +918,13 @@ pub(crate) fn field(ui: &mut egui::Ui, s: &mut String) -> bool { r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) } +/// A checkbox row. Its row handle (`dialog:`) lets a scenario click it by the label +/// beside it, the way `field` already publishes one for a value field. +pub(crate) fn check(ui: &mut egui::Ui, on: &mut bool) { + let r = ui.checkbox(on, ""); + publish_row(&r); +} + pub(crate) fn combo(ui: &mut egui::Ui, id: &str, labels: &[&str], sel: &mut usize) { let row = ROW.with(|r| r.borrow().clone()); let selected = labels.get(*sel).copied().map(tr).unwrap_or(""); diff --git a/crates/ui-egui/src/locales/de.json b/crates/ui-egui/src/locales/de.json index cd6fc1d9..38d097c4 100644 --- a/crates/ui-egui/src/locales/de.json +++ b/crates/ui-egui/src/locales/de.json @@ -46,6 +46,7 @@ "ui.cancel": "Abbrechen", "ui.canvases": "Kanvas", "ui.centerline": "Mittellinie", + "ui.chain": "Kette", "ui.chamfer": "Abschrägung", "ui.change_workspace": "Arbeitsbereich wechseln", "ui.chord_length": "Sehnenlänge", @@ -74,6 +75,7 @@ "ui.create_sketch": "Skizze erstellen", "ui.current": "aktuell", "ui.curvature": "Krümmung", + "ui.curves": "Kurven", "ui.cut": "Ausschneiden", "ui.cylinder": "Zylinder", "ui.dark": "Dunkel", @@ -143,6 +145,7 @@ "ui.heading_mirror": "SPIEGELN", "ui.heading_modify": "ÄNDERN", "ui.heading_move": "VERSCHIEBEN", + "ui.heading_offset": "ABSTAND", "ui.heading_offset_plane": "ABSTANDSEBENE", "ui.heading_ok": "OK", "ui.heading_parameters": "PARAMETER", diff --git a/crates/ui-egui/src/locales/en.json b/crates/ui-egui/src/locales/en.json index b9e8d585..d06ffc44 100644 --- a/crates/ui-egui/src/locales/en.json +++ b/crates/ui-egui/src/locales/en.json @@ -46,6 +46,7 @@ "ui.cancel": "Cancel", "ui.canvases": "Canvases", "ui.centerline": "Centerline", + "ui.chain": "Chain", "ui.chamfer": "Chamfer", "ui.change_workspace": "Change workspace", "ui.chord_length": "Chord Length", @@ -74,6 +75,7 @@ "ui.create_sketch": "Create Sketch", "ui.current": "current", "ui.curvature": "Curvature", + "ui.curves": "Curves", "ui.cut": "Cut", "ui.cylinder": "Cylinder", "ui.dark": "Dark", @@ -143,6 +145,7 @@ "ui.heading_mirror": "MIRROR", "ui.heading_modify": "MODIFY", "ui.heading_move": "MOVE", + "ui.heading_offset": "OFFSET", "ui.heading_offset_plane": "OFFSET PLANE", "ui.heading_ok": "OK", "ui.heading_parameters": "PARAMETERS", diff --git a/crates/ui-egui/src/locales/ja.json b/crates/ui-egui/src/locales/ja.json index ccd5f8fe..c57ca04b 100644 --- a/crates/ui-egui/src/locales/ja.json +++ b/crates/ui-egui/src/locales/ja.json @@ -46,6 +46,7 @@ "ui.cancel": "キャンセル", "ui.canvases": "キャンバス", "ui.centerline": "中心線", + "ui.chain": "チェーン", "ui.chamfer": "面取り", "ui.change_workspace": "ワークスペースの変更", "ui.chord_length": "弦長", @@ -74,6 +75,7 @@ "ui.create_sketch": "スケッチを作成", "ui.current": "現在", "ui.curvature": "曲率", + "ui.curves": "曲線", "ui.cut": "切り取り", "ui.cylinder": "円柱", "ui.dark": "ダーク", @@ -143,6 +145,7 @@ "ui.heading_mirror": "ミラー", "ui.heading_modify": "修正", "ui.heading_move": "移動", + "ui.heading_offset": "オフセット", "ui.heading_offset_plane": "オフセット平面", "ui.heading_ok": "OK", "ui.heading_parameters": "パラメータ", diff --git a/crates/ui-egui/src/locales/ko.json b/crates/ui-egui/src/locales/ko.json index 817c28f8..0cdf9d5c 100644 --- a/crates/ui-egui/src/locales/ko.json +++ b/crates/ui-egui/src/locales/ko.json @@ -46,6 +46,7 @@ "ui.cancel": "취소", "ui.canvases": "캔버스", "ui.centerline": "중심선", + "ui.chain": "체인", "ui.chamfer": "모따기", "ui.change_workspace": "작업 공간 변경", "ui.chord_length": "현 길이", @@ -74,6 +75,7 @@ "ui.create_sketch": "스케치 만들기", "ui.current": "현재", "ui.curvature": "곡률", + "ui.curves": "곡선", "ui.cut": "잘라내기", "ui.cylinder": "원통", "ui.dark": "어둡게", @@ -143,6 +145,7 @@ "ui.heading_mirror": "대칭 복사", "ui.heading_modify": "수정", "ui.heading_move": "이동", + "ui.heading_offset": "오프셋", "ui.heading_offset_plane": "오프셋 평면", "ui.heading_ok": "확인", "ui.heading_parameters": "매개변수", diff --git a/crates/ui-egui/src/sketch_corner_ui_tests.rs b/crates/ui-egui/src/sketch_corner_ui_tests.rs index 449521cd..32b346da 100644 --- a/crates/ui-egui/src/sketch_corner_ui_tests.rs +++ b/crates/ui-egui/src/sketch_corner_ui_tests.rs @@ -30,8 +30,26 @@ fn clicked_periodic_fit_closes_at_start_and_right_click_keeps_natural() { assert!(matches!(sk.curves[1].kind, CurveKind::Spline { periodic: false, .. })); } +/// Curve ids in the open dialog's first selection input. +fn dialog_curves(h: &crate::scenario::Harness) -> Vec { + h.app + .dialog + .as_ref() + .and_then(|d| d.inputs.first()) + .map(|i| i.items.iter().filter_map(|x| if let solidcraft_engine::Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect()) + .unwrap_or_default() +} + +/// Type `value` into the open Offset window's Distance row, then apply with OK. +fn offset_distance(h: &mut crate::scenario::Harness, value: &str) { + h.step(&json!({"click":{"handle":"dialog:Distance"}})).unwrap(); + h.step(&json!({"key":"A","cmd":true})).unwrap(); + h.step(&json!({"text":value})).unwrap(); + h.step(&json!({"widget":"OK"})).unwrap(); +} + #[test] -fn clicked_ellipse_offset_then_closed_offset_preserves_exact_distance_and_undo() { +fn offset_window_distance_and_flip_offset_an_ellipse_exactly_and_undo_restores() { let mut h = crate::scenario::Harness::new(); h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); h.app.run("sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":5})).unwrap(); @@ -40,19 +58,25 @@ fn clicked_ellipse_offset_then_closed_offset_preserves_exact_distance_and_undo() h.step(&json!({"start":"sketch.offset"})).unwrap(); // Major tip is also a source handle: Offset must choose the actual curve. h.step(&json!({"click":{"world":[10,0,0]}})).unwrap(); - assert_eq!(h.app.tool.as_ref().unwrap().picks, vec!["e1"]); - h.step(&json!({"click":{"world":[11,0,0]}})).unwrap(); + assert_eq!(dialog_curves(&h), vec!["e1".to_string()], "{:?}", h.app.status); + // Nothing is committed until the window is applied. + assert!(h.app.session.model.state().sketch(sid).unwrap().sketch.freeform_offsets.is_empty()); + h.step(&json!({"click":{"handle":"dialog:Flip"}})).unwrap(); + offset_distance(&mut h, "1 mm"); + assert!(h.app.dialog.is_none(), "{:?}", h.app.status); let sk = h.app.session.model.state().sketch(sid).unwrap().sketch.clone(); assert_eq!(sk.freeform_offsets.len(), 1, "{:?}", h.app.status); - assert!((sk.freeform_offsets[0].distance + 1.0).abs() < 1e-4); + assert!((sk.freeform_offsets[0].distance + 1.0).abs() < 1e-4, "{:?}", sk.freeform_offsets[0].distance); let first = sk.freeform_offsets[0].output.clone(); let saved = h.app.session.doc.to_json(); + h.step(&json!({"start":"sketch.offset"})).unwrap(); h.step(&json!({"click":{"world":[11,0,0]}})).unwrap(); - assert_eq!(h.app.tool.as_ref().unwrap().picks, vec![first]); - h.step(&json!({"click":{"world":[11.5,0,0]}})).unwrap(); + assert_eq!(dialog_curves(&h), vec![first], "{:?}", h.app.status); + h.step(&json!({"click":{"handle":"dialog:Flip"}})).unwrap(); + offset_distance(&mut h, "0.5 mm"); let sk = h.app.session.model.state().sketch(sid).unwrap().sketch.clone(); assert_eq!(sk.freeform_offsets.len(), 2, "{:?}", h.app.status); - assert!((sk.freeform_offsets[1].distance + 0.5).abs() < 1e-4); + assert!((sk.freeform_offsets[1].distance + 0.5).abs() < 1e-4, "{:?}", sk.freeform_offsets[1].distance); assert!(sk.freeform_offsets.iter().all(|r| r.certificate == 2)); assert_eq!(solidcraft_engine::sketch::find_profiles(&sk).len(), 3); let last = sk.curve_index(&sk.freeform_offsets[1].output).unwrap(); @@ -61,6 +85,20 @@ fn clicked_ellipse_offset_then_closed_offset_preserves_exact_distance_and_undo() assert_eq!(h.app.session.doc.to_json(), saved); } +#[test] +fn offset_window_without_a_curve_reports_it_and_keeps_the_window_open() { + let mut h = crate::scenario::Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.app.run("sketch.ellipse", json!({"center":[0,0],"major":[10,0],"minor_radius":5})).unwrap(); + h.settle(); + let sid = h.app.session.active_sketch.unwrap(); + h.step(&json!({"start":"sketch.offset"})).unwrap(); + h.step(&json!({"widget":"OK"})).unwrap(); + let error = h.app.dialog.as_ref().and_then(|d| d.error.clone()); + assert_eq!(error.as_deref(), Some("Select the curves to offset first")); + assert!(h.app.session.model.state().sketch(sid).unwrap().sketch.freeform_offsets.is_empty()); +} + #[test] fn clicked_extend_endpoint_reaches_exact_ellipse_and_preserves_support() { let mut h = crate::scenario::Harness::new(); diff --git a/crates/ui-egui/src/sketch_modify_ui.rs b/crates/ui-egui/src/sketch_modify_ui.rs index 1ff70525..957ae895 100644 --- a/crates/ui-egui/src/sketch_modify_ui.rs +++ b/crates/ui-egui/src/sketch_modify_ui.rs @@ -1,18 +1,19 @@ //! Tool windows for sketch-space modify commands, on the `dialogs_surface` pattern (#19): one //! `Mod` enum behind one `Kind::SketchModify` variant, grown by one variant and one arm per -//! command rather than by a new module each time. The first variant here is Scale, whose -//! toolbar button reached no tool and no dialog at all, so a press ran `sketch.scale` with no -//! parameters and the engine answered "`factor` must be a positive number". -//! `sketch.offset`, `sketch.fillet`, the three `sketch.chamfer.*` commands, the sketch -//! patterns, `sketch.mirror` and `sketch.move` are the same gap and join this module later, -//! each its own change. +//! command rather than by a new module each time. Scale came first, whose toolbar button +//! reached no tool and no dialog at all, so a press ran `sketch.scale` with no parameters and +//! the engine answered "`factor` must be a positive number". Offset joins it here (upstream +//! issue #162 item 1: "Offset does not create a constraint, the size of the offset seems +//! random"). `sketch.fillet`, the three `sketch.chamfer.*` commands, the sketch patterns, +//! `sketch.mirror` and `sketch.move` are the same gap and join this module later, each its own +//! change. 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, field, row_label}; +use crate::dialogs::{Kind, check, field, row_label}; use crate::selection::{CURVES, POINTS, SelInput}; /// A sketch modify tool window's values. @@ -22,12 +23,17 @@ pub enum Mod { /// input; with none picked the command scales about the sketch origin, as its own default /// already does. Scale { factor: String }, + /// `sketch.offset`: the curves to offset, the distance typed and whether the connected + /// chain follows. A positive distance is left of the chain, as the command defines it; + /// `flip` reverses whatever sign the field holds. + Offset { distance: String, chain: bool, flip: bool }, } impl Mod { pub fn title(&self) -> &'static str { match self { Mod::Scale { .. } => "SKETCH SCALE", + Mod::Offset { .. } => "OFFSET", } } @@ -35,6 +41,7 @@ impl Mod { pub fn primary(&mut self) -> Option<(&'static str, ValueKind, &mut String)> { Some(match self { Mod::Scale { factor } => ("Factor", ValueKind::Unitless, factor), + Mod::Offset { distance, .. } => ("Distance", ValueKind::Length, distance), }) } } @@ -42,6 +49,7 @@ impl Mod { fn inputs(k: &Mod) -> Vec { match k { Mod::Scale { .. } => vec![SelInput::new("Entities", CURVES | POINTS, true), SelInput::new("Base point", POINTS, false)], + Mod::Offset { .. } => vec![SelInput::new("Curves", CURVES, true)], } } @@ -49,6 +57,7 @@ fn inputs(k: &Mod) -> Vec { pub fn start(_app: &SolveApp, id: &str) -> Option<(Kind, Vec)> { let k = match id { "sketch.scale" => Mod::Scale { factor: "2".into() }, + "sketch.offset" => Mod::Offset { distance: "2 mm".into(), chain: true, flip: false }, _ => return None, }; let i = inputs(&k); @@ -64,6 +73,17 @@ pub fn rows(ui: &mut egui::Ui, k: &mut Mod) -> bool { enter |= field(ui, factor); ui.end_row(); } + Mod::Offset { distance, chain, flip } => { + row_label(ui, "Distance"); + enter |= field(ui, distance); + ui.end_row(); + row_label(ui, "Flip"); + check(ui, flip); + ui.end_row(); + row_label(ui, "Chain"); + check(ui, chain); + ui.end_row(); + } } enter } @@ -94,6 +114,11 @@ fn entity_ids(inputs: &[SelInput], i: usize) -> Vec { .collect() } +/// Sketch curve ids picked in input `i`. +fn curve_ids(inputs: &[SelInput], i: usize) -> Vec { + items(inputs, i).iter().filter_map(|x| if let Sel::SketchCurve { id } = x { Some(id.clone()) } else { None }).collect() +} + /// A typed scale factor as the command wants it: the field evaluated against the document's /// parameters, in the range the command accepts. Checking the range here lets the window say /// so in its own error line instead of the status bar. @@ -105,9 +130,21 @@ fn factor(app: &SolveApp, text: &str) -> Result { Ok(v) } +/// A typed distance as the command wants it: the field evaluated against the document's +/// parameters, with the sign reversed when Flip is set. A bare number means the design's units, +/// as a typed sketch value does everywhere else. +fn signed_length(app: &SolveApp, text: &str, flip: bool) -> Result { + let doc = &app.session.doc; + let v = doc.eval(&doc.with_design_unit(text.trim(), ValueKind::Length), ValueKind::Length).map_err(|e| format!("Distance: {e}"))?; + if !v.is_finite() || v.abs() <= 1e-9 { + return Err("Distance must be a non-zero number".into()); + } + Ok(if flip { -v } else { v }) +} + /// The command a sketch modify dialog makes. `sketch.scale` takes a plain evaluated number for -/// its factor, not an expression string: it has no model parameter to carry one, unlike -/// `sketch.fillet`'s radius. +/// its factor and `sketch.offset` one for its distance, not an expression string: neither has a +/// model parameter to carry one, unlike `sketch.fillet`'s radius. pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map) -> Result, String> { let (cmd, mut params) = match k { Mod::Scale { factor: text } => { @@ -122,6 +159,14 @@ pub fn commands(app: &SolveApp, k: &Mod, inputs: &[SelInput], extra: &Map { + let curves = curve_ids(inputs, 0); + if curves.is_empty() { + return Err("Select the curves to offset first".into()); + } + let d = signed_length(app, distance, *flip)?; + ("sketch.offset", json!({"curves": curves, "distance": d, "chain": chain})) + } }; if let Value::Object(m) = &mut params { for (key, value) in extra { diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 57590aec..ac63328c 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -357,7 +357,6 @@ fn mode(id: &str) -> Option { "sketch.text" => Mode::Points(1), "sketch.edit_text" => Mode::Entities(1), "sketch.blend_curve" => Mode::Points(2), - "sketch.offset" => Mode::Entities(1), "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), @@ -415,7 +414,6 @@ pub fn hint(id: &str) -> Option { "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.offset" => "Offset: click a curve (its chain is offset), then the side and distance", "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" @@ -709,30 +707,6 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) { } return; } - if cmd == "sketch.offset" && !tool.picks.is_empty() { - // Dense derived fit nodes must not snap a nearby side click back - // onto the source, reducing a deliberate offset to zero. - let state = app.session.world_state(); - let side = active_sketch(app).and_then(|sid| state.sketch(sid)).and_then(|ss| { - let (o, d) = proj.ray(pos); - ss.plane.intersect_ray(o, d).map(|p| ss.plane.to_local(p)) - }); - if let Some(p) = side { - offset_side(app, tool, p); - } - return; - } - if cmd == "sketch.offset" { - if let Some(sid) = active_sketch(app) - && let Some(id) = hits.iter().find_map(|hit| match hit { - Hit::SketchCurve { sketch, id, .. } if *sketch == sid => Some(id.clone()), - _ => None, - }) - { - tool.picks.push(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) => { @@ -961,30 +935,6 @@ pub fn finish(app: &mut SolveApp, tool: &mut Tool) -> bool { } } -/// Offset: the second click sets the side and distance. -fn offset_side(app: &mut SolveApp, tool: &mut Tool, p: Vec2) -> bool { - if tool.cmd != "sketch.offset" || tool.picks.is_empty() { - return false; - } - let st = app.session.world_state(); - let d = active_sketch(app) - .and_then(|s| st.sketch(s)) - .and_then(|ss| { - let curve = ss.sketch.curve_index(&tool.picks[0])?; - if let Some(shape) = ss.sketch.shape(curve) { - Some(shape.dist(p)) - } else { - ss.sketch.exact_segs(curve)?.iter().map(|s| s.point_at(s.closest_param(p)).dist(p)).min_by(f64::total_cmp) - } - }) - .unwrap_or(0.0); - if d > 1e-6 { - let _ = app.run("sketch.offset", json!({"curves": [tool.picks[0]], "distance": d, "side": xy(p)})); - } - tool.picks.clear(); - true -} - /// Rubber band for point tools and pick markers. pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { let Some(sid) = active_sketch(app) else { return }; diff --git a/crates/ui-egui/tests/scenarios/sketch_offset_window.json b/crates/ui-egui/tests/scenarios/sketch_offset_window.json new file mode 100644 index 00000000..9f232fca --- /dev/null +++ b/crates/ui-egui/tests/scenarios/sketch_offset_window.json @@ -0,0 +1,82 @@ +[ + {"note": "#162 item 1 (\"the size of the offset seems random\"): sketch.offset gets a tool window carrying Curves, Distance, Flip and Chain, instead of a bare click-pick that measured the distance off the second click."}, + {"start": "sketch.create"}, + {"click": {"plane": "XY"}}, + {"expect": {"sketching": true}}, + {"start": "sketch.circle.center"}, + {"click": {"sketch": [0, 0]}}, + {"click": {"sketch": [10, 0]}}, + {"key": "Escape"}, + {"expect": {"sketch_curves": {"own": 1}, "errors": 0}}, + + {"note": "Starting the command opens the window and commits nothing."}, + {"start": "sketch.offset"}, + {"expect": {"dialog": "Offset", "sketch_curves": {"own": 1}, "errors": 0}}, + {"click": {"sketch": [10, 0]}}, + {"expect": {"dialog": "Offset", "sketch_curves": {"own": 1}, "errors": 0}}, + + {"note": "The typed number reaches the command unchanged: a 10 mm circle offset 3 mm is a 7 mm circle."}, + {"click": {"handle": "dialog:Distance"}}, + {"key": "A", "cmd": true}, + {"text": "3"}, + {"key": "Enter"}, + {"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 2}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 1, "radius"], "value": 7}}}, + + {"note": "Flip reverses the side: the same 3 mm grows the circle to 13 mm."}, + {"start": "sketch.offset"}, + {"click": {"sketch": [10, 0]}}, + {"click": {"handle": "dialog:Flip"}}, + {"click": {"handle": "dialog:Distance"}}, + {"key": "A", "cmd": true}, + {"text": "3"}, + {"key": "Enter"}, + {"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 3}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 2, "radius"], "value": 13}}}, + {"shot": "sketch_offset_circle"}, + {"start": "sketch.finish"}, + + {"note": "Chain on (the default): one picked edge of a rectangle offsets the whole connected chain, four curves. The inner rectangle is 34 by 24, so the offset came from the typed 3 mm on every side."}, + {"start": "sketch.create"}, + {"click": {"plane": "XZ"}}, + {"start": "sketch.rectangle.two_point"}, + {"click": {"sketch": [0, 0]}}, + {"click": {"sketch": [40, 30]}}, + {"key": "Escape"}, + {"expect": {"sketch_curves": {"own": 4}, "errors": 0}}, + {"start": "sketch.offset"}, + {"click": {"sketch": [20, 0]}}, + {"expect": {"dialog": "Offset", "sketch_curves": {"own": 4}}}, + {"click": {"handle": "dialog:Distance"}}, + {"key": "A", "cmd": true}, + {"text": "3"}, + {"key": "Enter"}, + {"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 8}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["profiles", 1, "area_mm2"], "value": 816}}}, + {"shot": "sketch_offset_chain"}, + {"start": "sketch.finish"}, + + {"note": "Chain cleared: the same pick offsets that one curve, so the sketch gains one."}, + {"start": "sketch.create"}, + {"click": {"plane": "YZ"}}, + {"start": "sketch.rectangle.two_point"}, + {"click": {"sketch": [0, 0]}}, + {"click": {"sketch": [40, 30]}}, + {"key": "Escape"}, + {"expect": {"sketch_curves": {"own": 4}, "errors": 0}}, + {"start": "sketch.offset"}, + {"click": {"sketch": [20, 0]}}, + {"click": {"handle": "dialog:Chain"}}, + {"click": {"handle": "dialog:Distance"}}, + {"key": "A", "cmd": true}, + {"text": "3"}, + {"key": "Enter"}, + {"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 5}}}, + + {"note": "Escape closes the window and leaves the sketch exactly as it was."}, + {"start": "sketch.offset"}, + {"click": {"sketch": [20, 0]}}, + {"expect": {"dialog": "Offset"}}, + {"key": "Escape"}, + {"expect": {"dialog": null, "errors": 0, "sketch_curves": {"own": 5}}} +]