From 90873e51bb66525f83c0bdd3aba5d9197a4966c8 Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 15:19:07 +0000 Subject: [PATCH] Construction mode like Fusion: with nothing selected, X (or the toolbar's Construction, or the Sketch Palette's Linetype) switches the drawing line type, so new curves are construction (or centerline) until switched back, the active mode highlighted in the palette and named in the status bar; with a selection they convert it as before; the line type is part of the drawing's undo step. Scenarios: s35 construction mode, s36 the Sketch Palette shows while sketching (also over the tools, back after a window closes, both themes) and goes with the sketch; harness shown/not_shown checks Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/ui-egui/src/lib.rs | 19 ++-- crates/ui-egui/src/scenario.rs | 7 ++ crates/ui-egui/src/sketch_palette.rs | 85 +++++++++++++++- crates/ui-egui/tests/scenarios.rs | 2 + .../scenarios/s35_construction_mode.json | 47 +++++++++ .../scenarios/s36_sketch_palette_visible.json | 99 +++++++++++++++++++ 6 files changed, 246 insertions(+), 13 deletions(-) create mode 100644 crates/ui-egui/tests/scenarios/s35_construction_mode.json create mode 100644 crates/ui-egui/tests/scenarios/s36_sketch_palette_visible.json 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