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