diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 63b7a137..bd978758 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -340,6 +340,10 @@ impl SolveApp { let t0 = now_ms(); let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string()); self.big_operation |= now_ms() - t0 >= recovery_ui::BIG_OPERATION_MS; + if let Ok(v) = &r { + // Curves drawn in construction (or centerline) mode take that line type. + sketch_palette::apply_mode(self, id, v); + } match &r { Ok(_) => { self.session.echo(format!("{id}: done")); @@ -416,17 +420,10 @@ impl SolveApp { } } // Construction toggle: selected sketch curves switch at once, otherwise pick them. - if id == "sketch.construction" { - let curves: Vec = self - .session - .selection - .iter() - .filter_map(|s| if let solvecraft_engine::Sel::SketchCurve { id } = s { Some(id.clone()) } else { None }) - .collect(); - if !curves.is_empty() { - let _ = self.run(id, json!({ "curves": curves })); - return; - } + // Construction (X): convert the selection, or switch construction mode. + if id == "sketch.construction" && self.session.active_sketch.is_some() { + sketch_palette::toggle_construction(self); + return; } if let Some(spec) = solvecraft_engine::find_command(id) && (tools::Tool::for_command(id).is_some() || dialogs::Dialog::for_command(self, id).is_some()) diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index af2ba535..0b3c6851 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -563,6 +563,13 @@ pub fn check(h: &mut Harness, e: &Value) -> Result<(), String> { } } } + "shown" | "not_shown" => { + // A widget with this text is (not) on screen. + let t = v.as_str().unwrap_or_default(); + if h.widget(t).is_some() != (k == "shown") { + return Err(format!("widget `{t}`: want {}", if k == "shown" { "shown" } else { "not shown" })); + } + } "home" => { if ui["home"] != *v { return Err(format!("start page open: got {}, want {v}", ui["home"])); diff --git a/crates/ui-egui/src/sketch_palette.rs b/crates/ui-egui/src/sketch_palette.rs index a7b29e3b..18bde7c9 100644 --- a/crates/ui-egui/src/sketch_palette.rs +++ b/crates/ui-egui/src/sketch_palette.rs @@ -4,6 +4,9 @@ //! profiles, points, dimensions, constraints, projected geometry, 3D sketch); the sketch's //! degrees of freedom; Finish Sketch. Toggles run `sketch.options`, line types //! `sketch.construction` / `sketch.centerline`. +//! +//! The line type is also a drawing mode, as in Fusion: with nothing selected, Construction (or +//! X) makes what is drawn next construction geometry until it is switched back. use std::cell::Cell; @@ -15,6 +18,8 @@ use crate::SolveApp; use crate::theme::Tokens; thread_local! { + /// The drawing line type (0 normal, 1 construction, 2 centerline) and the sketch it is for. + static MODE: Cell<(u8, Option)> = const { Cell::new((0, None)) }; static FOLDED: Cell = const { Cell::new(false) }; static OPTIONS_FOLDED: Cell = const { Cell::new(false) }; } @@ -48,6 +53,74 @@ pub fn dof_text(dof: usize, ok: bool) -> String { } } +/// The line type new curves get in the sketch being edited (0 normal, 1 construction, +/// 2 centerline); a new sketch starts normal. +pub fn mode(app: &SolveApp) -> u8 { + let (m, sk) = MODE.with(Cell::get); + if sk.is_some() && sk == app.session.active_sketch { m } else { 0 } +} + +/// Switch the drawing line type (with a status hint). +pub fn set_mode(app: &mut SolveApp, m: u8) { + MODE.with(|c| c.set((m, app.session.active_sketch))); + let hint = match m { + 1 => "Construction mode: new curves are construction geometry (X or the palette's Normal to leave)", + 2 => "Centerline mode: new lines are centerlines", + _ => "Normal mode", + }; + app.set_status(hint, false); +} + +/// X / the toolbar's Construction: convert the selected curves, or with nothing selected +/// switch construction mode on or off. +pub fn toggle_construction(app: &mut SolveApp) { + let curves = selected_curves(app); + if curves.is_empty() { + let m = if mode(app) == 1 { 0 } else { 1 }; + set_mode(app, m); + } else { + let _ = app.run("sketch.construction", json!({ "curves": curves })); + } +} + +/// Curves a drawing command just made take the drawing line type. +pub fn apply_mode(app: &mut SolveApp, id: &str, result: &Value) { + let m = mode(app); + if m == 0 || !solvecraft_engine::find_command(id).is_some_and(|c| c.tab == "SKETCH" && c.panel == "CREATE") { + return; + } + let curves: Vec = result["curves"].as_array().into_iter().flatten().filter_map(|c| c.as_str().map(str::to_string)).collect(); + if curves.is_empty() { + return; + } + let lines: Vec = if m == 2 { + let st = app.session.model.state(); + let Some(ss) = app.session.active_sketch.and_then(|s| st.sketch(s)) else { return }; + curves + .iter() + .filter(|c| { + ss.sketch + .curve_index(c) + .and_then(|i| ss.sketch.curves.get(i)) + .is_some_and(|c| matches!(c.kind, solvecraft_engine::sketch::CurveKind::Line { .. })) + }) + .cloned() + .collect() + } else { + Vec::new() + }; + let rest: Vec = curves.iter().filter(|c| !lines.contains(c)).cloned().collect(); + // Part of the drawing's own undo step. + let depth = app.session.undo.len(); + if !lines.is_empty() { + let _ = app.session.execute("sketch.centerline", &json!({ "curves": lines, "value": true })); + } + if !rest.is_empty() { + let _ = app.session.execute("sketch.construction", &json!({ "curves": rest, "value": true })); + } + app.session.undo.truncate(depth); +} + /// Selected curves of the active sketch. fn selected_curves(app: &SolveApp) -> Vec { app.session.selection.iter().filter_map(|s| if let Sel::SketchCurve { id } = s { Some(id.clone()) } else { None }).collect() @@ -154,8 +227,12 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { egui::Grid::new("sc_palette_grid").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { ui.label("Linetype"); ui.horizontal(|ui| { + // With a selection: convert it; without: the mode new curves are drawn in. + let current = mode(app); for (i, (label, tip)) in [("—", "Normal"), ("- -", "Construction"), ("-·-", "Centerline")].iter().enumerate() { - if ui.add_enabled(has_curves, egui::Button::new(*label).min_size(vec2(30.0, 20.0))).on_hover_text(*tip).clicked() { + let on = !has_curves && current == i as u8; + let tip = if has_curves { format!("Make the selection {tip}") } else { format!("Draw {tip} curves") }; + if ui.add(egui::Button::new(*label).selected(on).min_size(vec2(30.0, 20.0))).on_hover_text(tip).clicked() { linetype = Some(i as u8); } } @@ -194,7 +271,11 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { } } if let Some(k) = linetype { - set_linetype(app, k); + if has_curves { + set_linetype(app, k); + } else { + set_mode(app, k); + } } if look { crate::dialogs::look_at_sketch(app); diff --git a/crates/ui-egui/tests/scenarios.rs b/crates/ui-egui/tests/scenarios.rs index d0c96927..2d445c44 100644 --- a/crates/ui-egui/tests/scenarios.rs +++ b/crates/ui-egui/tests/scenarios.rs @@ -52,6 +52,8 @@ scenarios!( s27_move_triad, s33_move_x_arrow, s34_component_move_carries_sketches, + s35_construction_mode, + s36_sketch_palette_visible ); /// Scenarios written ahead of their fixes or features (the user's open bugs): run with diff --git a/crates/ui-egui/tests/scenarios/s35_construction_mode.json b/crates/ui-egui/tests/scenarios/s35_construction_mode.json new file mode 100644 index 00000000..9ab0922d --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s35_construction_mode.json @@ -0,0 +1,47 @@ +[ + {"note": "Construction mode like Fusion: with nothing selected, X (or the palette's Construction linetype) makes new curves construction until switched back; with a selection X converts it."}, + {"start": "SketchCreate"}, + {"click": {"plane": "XY"}}, + {"expect": {"dialog": null}}, + {"key": "X"}, + {"expect": {"tool": null}}, + {"start": "ShapeRectangleTwoPoint"}, + {"click": {"sketch": [0, 0]}}, + {"click": {"sketch": [30, 20]}}, + {"key": "Escape"}, + {"start": "CircleCenterRadius"}, + {"click": {"sketch": [15, 10]}}, + {"click": {"sketch": [20, 10]}}, + {"key": "Escape"}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 4, "construction"], "value": true}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 0, "construction"], "value": true}}}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["profiles"], "value": []}}}, + {"key": "Z", "cmd": true}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 4], "value": null}}}, + {"key": "Y", "cmd": true}, + {"shot": "s35_construction_mode"}, + {"widget": "—"}, + {"start": "CircleCenterRadius"}, + {"click": {"sketch": [40, 10]}}, + {"click": {"sketch": [45, 10]}}, + {"key": "Escape"}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 5, "construction"], "value": false}}}, + {"widget": "- -"}, + {"start": "DrawPolyline"}, + {"click": {"sketch": [0, 30]}}, + {"click": {"sketch": [30, 30]}}, + {"key": "Escape"}, + {"key": "Escape"}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 6, "construction"], "value": true}}}, + {"key": "X"}, + {"click": {"sketch": [40, 15]}}, + {"key": "X"}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 5, "construction"], "value": true}}}, + {"key": "Escape"}, + {"start": "DrawPolyline"}, + {"click": {"sketch": [0, 40]}}, + {"click": {"sketch": [30, 40]}}, + {"key": "Escape"}, + {"key": "Escape"}, + {"expect": {"query": {"command": "sketch.inspect", "path": ["curves", 7, "construction"], "value": false}}} +] diff --git a/crates/ui-egui/tests/scenarios/s36_sketch_palette_visible.json b/crates/ui-egui/tests/scenarios/s36_sketch_palette_visible.json new file mode 100644 index 00000000..2ce71e52 --- /dev/null +++ b/crates/ui-egui/tests/scenarios/s36_sketch_palette_visible.json @@ -0,0 +1,99 @@ +[ + { + "note": "The Sketch Palette shows docked at the right whenever a sketch is active and no command dialog is open, comes back when the dialog closes, and goes with the sketch." + }, + { + "expect": { + "not_shown": "SKETCH PALETTE" + } + }, + { + "start": "SketchCreate" + }, + { + "expect": { + "dialog": "Sketch", + "not_shown": "SKETCH PALETTE" + } + }, + { + "click": { + "plane": "XY" + } + }, + { + "expect": { + "sketching": true, + "dialog": null, + "shown": "SKETCH PALETTE" + } + }, + { + "expect": { + "shown": "Finish Sketch" + } + }, + { + "start": "ShapeRectangleTwoPoint" + }, + { + "expect": { + "shown": "SKETCH PALETTE" + } + }, + { + "click": { + "sketch": [ + 0, + 0 + ] + } + }, + { + "click": { + "sketch": [ + 20, + 10 + ] + } + }, + { + "key": "Escape" + }, + { + "start": "ChangeParameterCommand" + }, + { + "widget": "Close window" + }, + { + "expect": { + "shown": "SKETCH PALETTE" + } + }, + { + "call": "ui.set", + "params": { + "dark": false + } + }, + { + "expect": { + "shown": "SKETCH PALETTE" + } + }, + { + "expect": { + "shown": "Finish Sketch" + } + }, + { + "widget": "Finish Sketch" + }, + { + "expect": { + "sketching": false, + "not_shown": "SKETCH PALETTE" + } + } +] \ No newline at end of file