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 <[email protected]>
This commit is contained in:
selune authored and GitHub committed 2026-10-13 04:07:48 -04:00
1 parent db02a18627
commit 083ddf7722
7 files changed
+163 -37

No files matched your search

+6 -6
View File
@@ -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"
+34
View File
@@ -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::<Vec<_>>(), before.dims.iter().map(|d| &d.value).collect::<Vec<_>>());
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)] {
+11 -8
View File
@@ -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<Pos2> = 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);
+27 -10
View File
@@ -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<Vec2> = 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]) => {
+75 -13
View File
@@ -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<Pos2> = 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<Color32> {