From 7757e6d9c7768e40be12f04d3b8967635528a6ee Mon Sep 17 00:00:00 2001 From: bherbruck Date: Thu, 8 Oct 2026 05:21:14 +0000 Subject: [PATCH] Construction toggle on every curve type: the selection switches as a whole (slots, polygons; to construction unless all already are), projected curves too; construction curves are drawn dashed and centerlines dash-dot; profiles update at once Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL --- crates/engine/src/cmd/sketch.rs | 20 ++++++--- crates/engine/src/cmd/sketch_ux_tests.rs | 54 ++++++++++++++++++++++++ crates/ui-egui/src/viewport.rs | 44 ++++++++++++++++++- 3 files changed, 109 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index 073e0e66..dac9b673 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -149,7 +149,7 @@ pub static COMMANDS: &[CommandSpec] = &[ .icon("construction") .key("X") .enabled(in_sketch) - .params("curves: [ids], value?: bool (default toggles)"), + .params("curves: [ids], value?: bool (default: construction unless all of them already are)"), CommandSpec::new("sketch.delete", "Delete Sketch Entities", sketch_delete) .enabled(in_sketch) .params("entities: [curve, point or constraint ids]"), @@ -1283,17 +1283,23 @@ fn construction(s: &mut Session, p: &Value) -> Result { } let want = bool_(p, "value"); let (n, info) = edit(s, p, cmd, false, |sk, _| { + let cs: Vec = + ids.iter().map(|id| sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))).collect::>()?; + // The selection switches together (a slot or polygon as a whole): to construction + // unless all of it already is. Projected curves may switch too. + let to = want.unwrap_or_else(|| !cs.iter().all(|c| sk.curves.get(*c).is_some_and(|c| c.construction))); let mut n = 0; - for id in &ids { - let c = sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))?; - if let Some(cu) = sk.curves.get_mut(c) { - cu.construction = want.unwrap_or(!cu.construction); + for c in cs { + if let Some(cu) = sk.curves.get_mut(c) + && cu.construction != to + { + cu.construction = to; n += 1; } } - Ok(n) + Ok((n, to)) })?; - Ok(json!({"changed": n, "sketch": info})) + Ok(json!({"changed": n.0, "construction": n.1, "sketch": info})) } fn sketch_delete(s: &mut Session, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/sketch_ux_tests.rs b/crates/engine/src/cmd/sketch_ux_tests.rs index 70b2a400..e4b72ade 100644 --- a/crates/engine/src/cmd/sketch_ux_tests.rs +++ b/crates/engine/src/cmd/sketch_ux_tests.rs @@ -198,3 +198,57 @@ fn projected_geometry_counts_as_fixed() { assert_eq!(r["result"]["changed"], 0); assert!(sketch(&s).curve_locked(ci)); } + +// --------------------------------------------------------------------------------------------- +// Construction toggle + +fn profiles(s: &mut Session) -> i64 { + run(s, "sketch.solve", json!({}))["profiles"].as_i64().unwrap_or(-1) +} + +fn curve_ids(v: &Value) -> Vec { + v["curves"].as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default() +} + +#[test] +fn construction_toggle_takes_shapes_out_of_profiles_and_back() { + let mut s = new_sketch(); + let rect = curve_ids(&run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [20, 10]}))); + let slot = curve_ids(&run(&mut s, "ShapeSlotCenterToCenter", json!({"p0": [40, 5], "p1": [60, 5], "width": 6}))); + let poly = curve_ids(&run(&mut s, "ShapePolygonInscribed", json!({"center": [0, 40], "radius": 8, "sides": 6}))); + let circ = curve_ids(&run(&mut s, "CircleCenterRadius", json!({"center": [40, 40], "radius": 5}))); + let ell = curve_ids(&run(&mut s, "CircleElipse", json!({"center": [70, 40], "major": [80, 40], "minor_radius": 4}))); + assert_eq!(profiles(&mut s), 5); + for (shape, left) in [(&rect, 4), (&slot, 3), (&poly, 2), (&circ, 1), (&ell, 0)] { + let r = run(&mut s, "sketch.construction", json!({ "curves": shape })); + assert_eq!(r["construction"], true); + assert_eq!(profiles(&mut s), left, "{shape:?}"); + } + // Back again in one go. + let all: Vec = [rect, slot, poly, circ, ell].concat(); + let r = run(&mut s, "sketch.construction", json!({ "curves": all })); + assert_eq!(r["construction"], false); + assert_eq!(profiles(&mut s), 5); + // A mixed selection becomes construction as a whole. + run(&mut s, "sketch.construction", json!({"curves": ["l1"]})); + let r = run(&mut s, "sketch.construction", json!({"curves": ["l1", "l2", "l3", "l4"]})); + assert_eq!(r["construction"], true); + assert!(sketch(&s).curves.iter().filter(|c| ["l1", "l2", "l3", "l4"].contains(&c.id.as_str())).all(|c| c.construction)); + assert!(s.execute("sketch.construction", &json!({"curves": ["nope"]})).is_err()); +} + +#[test] +fn projected_geometry_can_become_construction() { + let mut s = Session::default(); + run(&mut s, "SketchCreate", json!({"plane": "XY"})); + run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [20, 10]})); + run(&mut s, "SketchStop", json!({})); + run(&mut s, "Extrude", json!({"distance": 5})); + run(&mut s, "SketchCreate", json!({"plane": "XY"})); + run(&mut s, "ProjectNewCmd", json!({"refs": [{"edge": [10, 0, 5]}]})); + let id = sketch(&s).curves.iter().find(|c| c.link.is_some()).map(|c| c.id.clone()).expect("projected"); + let r = run(&mut s, "sketch.construction", json!({ "curves": [id.clone()] })); + assert_eq!(r["changed"], 1); + let sk = sketch(&s); + assert!(sk.curves.iter().any(|c| c.id == id && c.construction && c.link.is_some())); +} diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index 91300559..18a3d371 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -254,15 +254,55 @@ fn build_scene(app: &SolveApp) -> GpuScene { } } } + // Construction curves are dashed, centerlines dash-dot (lengths follow the grid). + let unit = grid_step(app.cam.half_height()).0; for (pts, col, cons) in sketch_lines(&ss.sketch, &ss.plane, active, &ss.report.curve_determined) { - for w in pts.windows(2) { - sc.line(w[0].to_f32(), w[1].to_f32(), sketch_color(&tk, col), if cons { 1.2 } else { 2.0 }, active); + 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]) + }; + 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); } } } sc } +/// 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(); + if pattern.is_empty() || pattern.iter().any(|l| !(l.is_finite() && *l > 0.0)) { + return pts.windows(2).map(|w| (w[0], w[1])).collect(); + } + let (mut k, mut left) = (0usize, pattern[0]); + for w in pts.windows(2) { + let (mut a, b) = (w[0], w[1]); + let mut len = (b - a).len(); + // Cap the pieces per segment so a huge zoom-out can't explode the scene. + let mut guard = 0; + while len > 1e-12 && guard < 10_000 { + guard += 1; + let step = left.min(len); + let q = a + (b - a) * (step / len); + if k % 2 == 0 { + out.push((a, q)); + } + left -= step; + len -= step; + a = q; + if left <= 1e-12 { + k = (k + 1) % pattern.len(); + left = pattern.get(k).copied().unwrap_or(1.0); + } + } + } + out +} + /// Colour as straight (unpremultiplied) sRGBA bytes for the GPU. fn c4(c: Color32) -> [u8; 4] { c.to_srgba_unmultiplied()