From 083ddf77221aa5f962b8dbd8649cb5478762fe86 Mon Sep 17 00:00:00 2001 From: selune <35337584+0xlillith@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:07:48 +0200 Subject: [PATCH] fix(ui): preserve construction tools and dashed feedback (#600) Construction (X) keeps the active sketch tool, its picked points and inline values while switching line type, and drawing previews and hover show the same dashed pattern as committed construction and centre lines. Fixes #584 and #581. Co-authored-by: lillith <35337584+0xlillith@users.noreply.github.com> --- ROADMAP.md | 5 ++ crates/ui-egui/src/lib.rs | 12 ++-- crates/ui-egui/src/sketch_palette.rs | 34 +++++++++++ crates/ui-egui/src/sketch_tools.rs | 19 +++--- crates/ui-egui/src/tools.rs | 37 ++++++++---- crates/ui-egui/src/viewport.rs | 88 ++++++++++++++++++++++++---- docs/book/src/guide/sketching.md | 5 ++ 7 files changed, 163 insertions(+), 37 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a82dbad7..bf81afb0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -61,6 +61,11 @@ PCB / Package, Render, Animation, Simulation, Manufacture/CAM, Drawings. ## Dimensioning and error reporting +- Construction (X) preserves the active sketch drawing tool, picked anchors and inline + dimension values, so line type can change while a shape is in progress. +- Planar sketch construction previews and hover feedback keep the same dashed pattern as + committed geometry; centerlines retain their dash-dot pattern. + - Point has a default Sketch CREATE toolbar button alongside the existing drawing tools. Saved custom layouts are preserved; Reset Toolbar restores the default Point button. - Construction planes cannot take an origin-plane name (XY, XZ, YZ, Top, Front, Right). Plane diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index aec926fd..ee8b2938 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -511,6 +511,12 @@ impl SolveApp { self.tree.capture_prompt = Some(id.to_string()); return; } + // A line-type toggle changes the current drawing, including a partly picked shape. + // Handle it before cancelling tools or inline dimensions. + if id == "sketch.construction" { + sketch_palette::toggle_construction(self); + return; + } dim_view::cancel(); self.tool = None; self.home.open = false; @@ -542,12 +548,6 @@ impl SolveApp { let edges = self.session.selection.iter().any(|s| matches!(s, solidcraft_engine::Sel::Edge { .. })); return self.start(if edges { "solid.fillet" } else { "solid.extrude" }); } - // Construction toggle: selected sketch curves switch at once, otherwise pick them. - // Construction (X): convert the selection, or switch construction mode; no pick tool. - if id == "sketch.construction" { - sketch_palette::toggle_construction(self); - return; - } if let Some(spec) = solidcraft_engine::find_command(id) && (tools::Tool::for_command(id).is_some() || dialogs::Dialog::for_command(self, id).is_some()) && id != "sketch.create" diff --git a/crates/ui-egui/src/sketch_palette.rs b/crates/ui-egui/src/sketch_palette.rs index 0efc746a..a07c9126 100644 --- a/crates/ui-egui/src/sketch_palette.rs +++ b/crates/ui-egui/src/sketch_palette.rs @@ -428,6 +428,40 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) { mod tests { use super::*; + #[test] + fn construction_shortcut_keeps_the_drawing_tool_and_commits_its_line_type() { + for command in ["sketch.line", "sketch.rectangle.two_point", "sketch.circle.center"] { + let mut h = crate::scenario::Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + set_mode(&mut h.app, 0); + h.step(&json!({"start":command})).unwrap(); + h.step(&json!({"key":"X"})).unwrap(); + assert_eq!(h.app.tool.as_ref().map(|t| t.cmd), Some(command)); + assert_eq!(mode(&h.app), 1); + h.step(&json!({"click":{"sketch":[0,0]}})).unwrap(); + h.step(&json!({"move":{"sketch":[20,10]}})).unwrap(); + let before = h.app.tool.clone().unwrap(); + let history = h.app.session.undo.len(); + // The toolbar takes the same start path, even while a dimension box has focus. + h.app.start("sketch.construction"); + h.app.start("sketch.construction"); + let after = h.app.tool.as_ref().unwrap(); + assert_eq!(after.cmd, before.cmd); + assert_eq!(after.pts, before.pts); + assert_eq!(after.dims_stage, before.dims_stage); + assert_eq!(after.dims.iter().map(|d| &d.value).collect::>(), before.dims.iter().map(|d| &d.value).collect::>()); + assert_eq!(h.app.session.undo.len(), history); + h.step(&json!({"click":{"sketch":[20,10]}})).unwrap(); + let state = h.app.session.world_state(); + let sketch = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch; + assert!(!sketch.curves.is_empty(), "{command}"); + assert!(sketch.curves.iter().all(|c| c.construction), "{command}"); + h.app.run("edit.undo", json!({})).unwrap(); + assert!(h.app.session.world_state().sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch.curves.is_empty()); + set_mode(&mut h.app, 0); + } + } + #[test] fn palette_footer_and_scrolled_lower_option_remain_reachable_at_scale_and_resize() { for (scale, height) in [(1.0, 900.0), (2.0, 900.0), (2.0, 720.0)] { diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 4f13a6c4..6284d348 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -973,19 +973,22 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { { painter.circle_stroke(sp, if snap.is_some() { 6.0 } else { 3.0 }, stroke); } - let pts: Vec = t.pts.iter().filter_map(|p| to(p.0)).collect(); - for w in pts.windows(2) { - painter.line_segment([w[0], w[1]], Stroke::new(1.0, tk.rubber_band.gamma_multiply(0.6))); + for w in t.pts.windows(2) { + crate::tools::paint_sketch_path( + &[ss.plane.to_world(w[0].0), ss.plane.to_world(w[1].0)], + crate::sketch_palette::mode(app), + painter, + proj, + Stroke::new(1.0, tk.rubber_band.gamma_multiply(0.6)), + ); } - if let (Some(last), Some((h, _))) = (pts.last(), t.hover.as_ref()) - && let Some(hs) = to(*h) - { - painter.line_segment([*last, hs], stroke); + if let (Some(last), Some((h, _))) = (t.pts.last(), t.hover.as_ref()) { + crate::tools::paint_sketch_path(&[ss.plane.to_world(last.0), ss.plane.to_world(*h)], crate::sketch_palette::mode(app), painter, proj, stroke); } if let Some((h, _)) = t.hover.as_ref() && let Some(curve) = crate::tools::preview_curve(t, *h, None) { - crate::tools::paint_preview_curve(&curve, &ss.plane, painter, proj, stroke); + crate::tools::paint_preview_curve(curve, &ss.plane, painter, proj, stroke, crate::sketch_palette::mode(app)); } // Picked curves highlighted. let mut display_budget = solidcraft_engine::render::SamplingBudget::new(32_768, 65_536); diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 76b0e360..f49be818 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -451,13 +451,34 @@ pub fn finish(app: &mut SolveApp) { /// Rubber-band preview of the shape being drawn. pub(crate) fn paint_preview_curve( - sketch: &solidcraft_engine::sketch::Sketch, + mut sketch: solidcraft_engine::sketch::Sketch, plane: &solidcraft_engine::geom::Plane, painter: &egui::Painter, proj: &Proj, stroke: Stroke, + mode: u8, ) { - paint_curve(sketch, 0, plane, painter, proj, stroke, &mut solidcraft_engine::render::SamplingBudget::new(4096, 65_536)); + for curve in &mut sketch.curves { + curve.centerline = mode == 2 && matches!(curve.kind, solidcraft_engine::sketch::CurveKind::Line { .. }); + curve.construction = mode != 0 && !curve.centerline; + } + paint_curve(&sketch, 0, plane, painter, proj, stroke, &mut solidcraft_engine::render::SamplingBudget::new(4096, 65_536)); +} + +pub(crate) fn paint_sketch_path(points: &[solidcraft_engine::geom::Vec3], mode: u8, painter: &egui::Painter, proj: &Proj, stroke: Stroke) { + if mode == 0 { + let screen: Vec<_> = points.iter().filter_map(|point| proj.to_screen(*point)).collect(); + if screen.len() >= 2 { + painter.add(egui::Shape::line(screen, stroke)); + } + return; + } + let unit = solidcraft_engine::view::grid_step(proj.cam.half_height()).0; + for (a, b) in crate::viewport::sketch_segments(points, mode, unit) { + if let (Some(a), Some(b)) = (proj.to_screen(a), proj.to_screen(b)) { + painter.line_segment([a, b], stroke); + } + } } pub(crate) fn paint_curve( @@ -470,10 +491,8 @@ pub(crate) fn paint_curve( budget: &mut solidcraft_engine::render::SamplingBudget, ) { for part in crate::sketch_display::curve_with_budget(sketch, index, plane, proj, painter.ctx().pixels_per_point(), budget).parts { - let screen: Vec<_> = part.into_iter().filter_map(|point| proj.to_screen(point)).collect(); - if screen.len() >= 2 { - painter.add(egui::Shape::line(screen, stroke)); - } + let mode = sketch.curves.get(index).map_or(0, |c| if c.centerline { 2 } else { u8::from(c.construction) }); + paint_sketch_path(&part, mode, painter, proj, stroke); } } @@ -589,14 +608,12 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { let Some((h, _)) = t.hover.clone() else { return }; let h = crate::sketch_dims::effective(app, t, h); if let Some(curve) = preview_curve(t, h, crate::sketch_dims::locked_value(app, t, "Sweep")) { - paint_preview_curve(&curve, &ss.plane, painter, proj, stroke); + paint_preview_curve(curve, &ss.plane, painter, proj, stroke, crate::sketch_palette::mode(app)); return; } let pts: Vec = t.pts.iter().map(|p| p.0).collect(); let line = |a: Vec2, b: Vec2| { - if let (Some(x), Some(y)) = (to(a), to(b)) { - painter.line_segment([x, y], stroke); - } + paint_sketch_path(&[ss.plane.to_world(a), ss.plane.to_world(b)], crate::sketch_palette::mode(app), painter, proj, stroke); }; match (t.cmd, pts.as_slice()) { ("sketch.line", [.., last]) => { diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 0c672dff..c8b38928 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -429,13 +429,17 @@ fn build_sketch_scene(app: &SolveApp, proj: &Proj) -> GpuScene { for (pts, col, cons) in sketch_lines_with_sampler(&ss.sketch, &ss.plane, active, &ss.report.curve_determined, |_, index, plane| { crate::sketch_display::curve_sample(app, sid, index, plane, proj).parts.clone() }) { - let segs = if !cons { - pts.windows(2).map(|w| (w[0], w[1])).collect() - } else if col == colors::SKETCH_CENTERLINE { - dashes(&pts, &[0.6 * unit, 0.15 * unit, 0.05 * unit, 0.15 * unit]) - } else { - dashes(&pts, &[0.3 * unit, 0.2 * unit]) - }; + let segs = sketch_segments( + &pts, + if !cons { + 0 + } else if col == colors::SKETCH_CENTERLINE { + 2 + } else { + 1 + }, + unit, + ); for (a, b) in segs { sc.line(a.to_f32(), b.to_f32(), sketch_color(&tk, col), if cons { 1.2 } else { 2.0 }, active); } @@ -502,6 +506,15 @@ fn ground_shadow(sc: &mut GpuScene, app: &SolveApp, st: &solidcraft_engine::doc: } } +/// Sketch line types share the same world-space dash phase in the scene and overlays. +pub(crate) fn sketch_segments(pts: &[Vec3], mode: u8, unit: f64) -> Vec<(Vec3, Vec3)> { + match mode { + 1 => dashes(pts, &[0.3 * unit, 0.2 * unit]), + 2 => dashes(pts, &[0.6 * unit, 0.15 * unit, 0.05 * unit, 0.15 * unit]), + _ => pts.windows(2).map(|w| (w[0], w[1])).collect(), + } +} + /// The drawn pieces of a polyline in a dash pattern (on, off, on, off… lengths). pub(crate) fn dashes(pts: &[Vec3], pattern: &[f64]) -> Vec<(Vec3, Vec3)> { let mut out = Vec::new(); @@ -1464,8 +1477,9 @@ fn build_highlight(app: &SolveApp) -> GpuScene { && let Some(ci) = ss.sketch.curve_index(id) { for pts in &crate::sketch_display::curve_sample(app, *sketch, ci, &ss.plane, &display_proj(app)).parts { - for w in pts.windows(2) { - sc.line(w[0].to_f32(), w[1].to_f32(), c4(t.sel_edge), 3.5, true); + let mode = ss.sketch.curves.get(ci).map_or(0, |c| if c.centerline { 2 } else { u8::from(c.construction) }); + for (a, b) in sketch_segments(pts, mode, grid_step(app.cam.half_height()).0) { + sc.line(a.to_f32(), b.to_f32(), c4(t.sel_edge), 3.5, true); } } } @@ -1485,8 +1499,9 @@ fn build_highlight(app: &SolveApp) -> GpuScene { && let Some(ci) = ss.sketch.curve_index(id) { for pts in &crate::sketch_display::curve_sample(app, ss.feature, ci, &ss.plane, &display_proj(app)).parts { - for w in pts.windows(2) { - sc.line(w[0].to_f32(), w[1].to_f32(), c4(t.sel_edge), 3.5, true); + let mode = ss.sketch.curves.get(ci).map_or(0, |c| if c.centerline { 2 } else { u8::from(c.construction) }); + for (a, b) in sketch_segments(pts, mode, grid_step(app.cam.half_height()).0) { + sc.line(a.to_f32(), b.to_f32(), c4(t.sel_edge), 3.5, true); } } } @@ -2576,8 +2591,8 @@ fn hover_highlight(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { && let Some(ci) = ss.sketch.curve_index(id) { for part in &crate::sketch_display::curve_sample(app, *sketch, ci, &ss.plane, proj).parts { - let pts: Vec = part.iter().filter_map(|q| proj.to_screen(*q)).collect(); - painter.add(Shape::line(pts, Stroke::new(3.0, col))); + let mode = ss.sketch.curves.get(ci).map_or(0, |c| if c.centerline { 2 } else { u8::from(c.construction) }); + crate::tools::paint_sketch_path(part, mode, painter, proj, Stroke::new(3.0, col)); } } } @@ -3070,6 +3085,53 @@ fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool { mod tests { use super::*; + #[test] + fn construction_drawing_and_hover_overlays_preserve_visible_gaps() { + fn length(shape: &Shape) -> f32 { + match shape { + Shape::LineSegment { points, .. } => points[0].distance(points[1]), + Shape::Path(path) => path.points.windows(2).map(|w| w[0].distance(w[1])).sum(), + Shape::Vec(shapes) => shapes.iter().map(length).sum(), + _ => 0.0, + } + } + let mut h = crate::scenario::Harness::new(); + h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); + h.call("ui.view", json!({"view":"top"})); + h.settle(); + let proj = display_proj(&h.app); + let a = Vec2::ZERO; + let b = Vec2::new(20.0, 8.0); + let whole = proj.to_screen(Vec3::new(b.x, b.y, 0.0)).unwrap().distance(proj.to_screen(Vec3::ZERO).unwrap()); + let mut draw = crate::tools::Tool::for_command("sketch.line").unwrap(); + draw.pts.push((a, None)); + draw.hover = Some((b, None)); + for mode in [0, 1, 2] { + crate::sketch_palette::set_mode(&mut h.app, mode); + let ctx = egui::Context::default(); + let output = ctx.run_ui(egui::RawInput::default(), |ui| { + let painter = ui.ctx().layer_painter(egui::LayerId::background()); + crate::tools::preview(&h.app, &draw, &painter, &proj); + }); + let preview: f32 = output.shapes.iter().map(|s| length(&s.shape)).sum(); + let r = h.app.run("sketch.line", json!({"points":[[0,0],[20,8]]})).unwrap(); + let id = r["curves"][0].as_str().unwrap().to_string(); + h.app.viewport.hover = Some(Hit::SketchCurve { sketch: h.app.session.active_sketch.unwrap(), id, straight: true }); + let output = ctx.run_ui(egui::RawInput::default(), |ui| { + hover_highlight(&h.app, &ui.ctx().layer_painter(egui::LayerId::background()), &proj); + }); + let hover: f32 = output.shapes.iter().map(|s| length(&s.shape)).sum(); + assert!((preview - hover).abs() < 0.01, "mode {mode}: preview {preview}, hover {hover}"); + if mode == 0 { + assert!((preview - whole).abs() < 0.01); + } else { + assert!(preview > whole * 0.25 && preview < whole * 0.85, "mode {mode} covers {preview} of {whole}"); + } + h.app.run("edit.undo", json!({})).unwrap(); + } + crate::sketch_palette::set_mode(&mut h.app, 0); + } + #[test] fn cpu_viewport_uses_section_preview_and_sketch_slice() { fn pixels(h: &mut crate::scenario::Harness, cam: Camera) -> Vec { diff --git a/docs/book/src/guide/sketching.md b/docs/book/src/guide/sketching.md index 9ec97dd8..63e96322 100644 --- a/docs/book/src/guide/sketching.md +++ b/docs/book/src/guide/sketching.md @@ -94,6 +94,11 @@ While drawing a fit spline, click its starting point after three distinct sample it smoothly. Right-click finishes an ordinary natural fit. Offset accepts both ellipse handles and dense fitted curves; the second click sets the side and distance from the curve. +While drawing in a planar sketch, **Construction** (X) changes the line type without +ending the tool or losing picked points and inline values. The preview and hover feedback +keep construction dashes and centerline dash-dot patterns visible. With curves selected, +Construction changes those curves as before. + Enable **3D Sketch** in the Sketch Palette to draw lines, fit splines and points off the plane. While placing these points, you can snap to points on other visible sketches. For a bridge line, start Line and click an endpoint on each source sketch; each endpoint uses its source