From aae70053ee21199485a1bb36d36b7113595b1162 Mon Sep 17 00:00:00 2001 From: William Dunn Date: Tue, 13 Oct 2026 11:47:28 -0400 Subject: [PATCH] Let the conic tool take a typed Rho, and preview the curve it is about to draw (#736) The conic tool takes a typed Rho in its canvas value box and previews the curve it's about to draw, with the tangent legs as a faint guide. --- crates/engine/src/cmd/sketch_drag.rs | 35 +++++++-- crates/engine/src/cmd/sketch_ux_tests.rs | 26 +++++++ crates/sketch/src/curves.rs | 42 +++++++++++ crates/sketch/src/lib.rs | 4 +- crates/ui-egui/src/sketch_dims.rs | 29 ++++++-- crates/ui-egui/src/sketch_dims_tests.rs | 60 ++++++++++++++++ .../src/sketch_display_preview_tests.rs | 49 +++++++++---- crates/ui-egui/src/sketch_tools.rs | 58 ++++++++++----- crates/ui-egui/src/tools.rs | 21 +++++- crates/ui-egui/tests/scenarios.rs | 72 +++++++++++++++++++ 10 files changed, 349 insertions(+), 47 deletions(-) diff --git a/crates/engine/src/cmd/sketch_drag.rs b/crates/engine/src/cmd/sketch_drag.rs index 189dd597..9e175e9b 100644 --- a/crates/engine/src/cmd/sketch_drag.rs +++ b/crates/engine/src/cmd/sketch_drag.rs @@ -15,7 +15,7 @@ use std::f64::consts::{PI, TAU}; use solidcraft_geom::Vec2; -use solidcraft_sketch::{Analysis, Budget, ConstraintKind, CurveKind, Hold, Sketch}; +use solidcraft_sketch::{Analysis, Budget, ConstraintKind, CurveKind, Hold, Sketch, conic_rho_from_point}; use super::{Ent, Left, counted_solve}; @@ -47,13 +47,18 @@ enum Path { Radius { c: usize, from: f64, to: f64 }, /// An arc's ends move along their rays from the centre `o` to the cursor's radius. ArcRadius { o: Vec2, ends: Vec<(usize, Vec2)>, from: f64, to: f64 }, + /// A conic's `rho` follows the cursor, read off its position between the chord's midpoint + /// and the apex (see [`solidcraft_sketch::conic_rho_from_point`]). `a`, `b` and `apex` stay + /// put; only the curve's fullness changes, the way an ellipse's outline changes its minor + /// radius rather than sliding the ellipse. + ConicRho { c: usize, from: f64, to: f64 }, } impl Path { fn length(&self) -> f64 { match self { Path::Slide { d, .. } => d.len(), - Path::Radius { from, to, .. } | Path::ArcRadius { from, to, .. } => (to - from).abs(), + Path::Radius { from, to, .. } | Path::ArcRadius { from, to, .. } | Path::ConicRho { from, to, .. } => (to - from).abs(), } } @@ -61,7 +66,7 @@ impl Path { fn points(&self) -> impl Iterator + '_ { let pts = match self { Path::Slide { pts, .. } | Path::ArcRadius { ends: pts, .. } => pts.as_slice(), - Path::Radius { .. } => &[], + Path::Radius { .. } | Path::ConicRho { .. } => &[], }; pts.iter().map(|(q, _)| *q) } @@ -90,13 +95,19 @@ impl Path { *r = from + (to - from) * t; } } + Path::ConicRho { c, from, to } => { + if let Some(CurveKind::Conic { rho, .. }) = sk.curves.get_mut(*c).map(|x| &mut x.kind) { + *rho = from + (to - from) * t; + } + } } } } -/// Drag: the grabbed entity follows the cursor exactly (a point goes to it; a line, -/// spline or conic slides by the cursor's move; a circle or arc grabbed on its edge changes -/// radius about its centre) and the rest of the sketch moves as little as it can, staying on the +/// Drag: the grabbed entity follows the cursor exactly (a point goes to it; a line or spline +/// slides by the cursor's move; a circle or arc grabbed on its edge changes radius about its +/// centre; a conic grabbed on its curve reads its fullness, `rho`, off the cursor) and the rest +/// of the sketch moves as little as it can, staying on the /// solution it started on. Fixed geometry stays. A step that cannot be solved with the entity /// held (the path crosses where the geometry cannot reach) is approached by halving the way to /// it, and the next steps are tried from the last solved state, the last of them being the whole @@ -430,8 +441,18 @@ fn plan(sk: &Sketch, e: Ent, from: Vec2, to: Vec2) -> Option<(Path, Hold)> { hold.radii.push(c); Path::Radius { c, from: r, to: if minor > 1e-9 { minor } else { r } } } + CurveKind::Conic { a, b, apex, rho } => { + // Grabbed on the curve itself: its fullness (`rho`) follows the drag, the + // way an ellipse's outline changes its minor radius rather than sliding the + // ellipse. `a`, `b` and the apex (the tangent intersection, dragged + // separately as an ordinary point) stay where they are. + let (pa, px, pb) = (sk.point(a)?, sk.point(apex)?, sk.point(b)?); + hold.points.extend([a, b, apex]); + let to_rho = conic_rho_from_point(pa, px, pb, to); + Path::ConicRho { c, from: rho, to: to_rho } + } k => { - // Lines, splines and conics slide with the cursor. + // Lines and splines slide with the cursor. let mut ids = k.point_ids(); ids.sort_unstable(); ids.dedup(); diff --git a/crates/engine/src/cmd/sketch_ux_tests.rs b/crates/engine/src/cmd/sketch_ux_tests.rs index 89f35ef8..450a15db 100644 --- a/crates/engine/src/cmd/sketch_ux_tests.rs +++ b/crates/engine/src/cmd/sketch_ux_tests.rs @@ -534,6 +534,32 @@ fn dragging_circles_and_arcs() { assert!(pt(&s, "a1.start").dist(v(70.0, 0.0)) < 1e-9, "{:?}", pt(&s, "a1.start")); } +#[test] +fn dragging_a_conic_reads_rho_off_the_cursor_and_its_apex_slides_like_any_point() { + let mut s = new_sketch(); + run(&mut s, "sketch.conic", json!({"start": [0, 0], "end": [40, 0], "apex": [20, 20]})); + let rho = |s: &Session| match sketch(s).curves[0].kind { + solidcraft_sketch::CurveKind::Conic { rho, .. } => rho, + ref k => panic!("not a conic: {k:?}"), + }; + assert!((rho(&s) - 0.5).abs() < 1e-9, "default rho: {}", rho(&s)); + + // Grabbed on the curve itself (its midpoint, at the default rho of 0.5): `rho` follows the + // cursor, read off where it falls between the chord's midpoint (20, 0) and the apex (20, + // 20). The three defining points stay exactly where they were. + drag(&mut s, "k1", [20.0, 10.0], [20.0, 14.0]); + assert!((rho(&s) - 0.7).abs() < 1e-6, "rho after drag: {}", rho(&s)); + assert!(pt(&s, "k1.start").dist(v(0.0, 0.0)) < 1e-9); + assert!(pt(&s, "k1.end").dist(v(40.0, 0.0)) < 1e-9); + assert!(pt(&s, "k1.apex").dist(v(20.0, 20.0)) < 1e-9); + + // Grabbed on the apex itself: it slides like any other point (not a `rho` edit), and `rho` + // is unaffected. + drag(&mut s, "k1.apex", [20.0, 20.0], [10.0, 30.0]); + assert!(pt(&s, "k1.apex").dist(v(10.0, 30.0)) < 1e-9); + assert!((rho(&s) - 0.7).abs() < 1e-6, "rho after apex drag: {}", rho(&s)); +} + #[test] fn constrained_geometry_resists_drags() { let mut s = new_sketch(); diff --git a/crates/sketch/src/curves.rs b/crates/sketch/src/curves.rs index a0579061..885b8786 100644 --- a/crates/sketch/src/curves.rs +++ b/crates/sketch/src/curves.rs @@ -33,6 +33,24 @@ pub fn conic_point(a: Vec2, apex: Vec2, b: Vec2, rho: f64, t: f64) -> Vec2 { (a * b0 + apex * b1 + b * b2) / den.max(1e-12) } +/// The `rho` that puts the conic's midpoint (`conic_point` at `t = 0.5`) at `p`, projected onto +/// the segment from the chord's midpoint to `apex`: at `t = 0.5`, `conic_point` is exactly +/// `mid + rho * (apex - mid)` (its two chord weights are equal there), so `rho` is `p`'s fraction +/// along that segment. Lets a UI turn a dragged or hovered point into a shape (drag the apex and +/// `rho` stays; drag the curve's midpoint handle and `rho` follows the drag instead). Clamped +/// well inside (0, 1) so the result is always a usable conic; `apex` on the chord's midpoint (no +/// segment to project onto) falls back to a parabola. +pub fn conic_rho_from_point(a: Vec2, apex: Vec2, b: Vec2, p: Vec2) -> f64 { + let mid = (a + b) * 0.5; + let d = apex - mid; + let len2 = d.len2(); + if !len2.is_finite() || len2 < 1e-12 { + return 0.5; + } + let t = (p - mid).dot(d) / len2; + if t.is_finite() { t.clamp(1e-3, 1.0 - 1e-3) } else { 0.5 } +} + pub fn conic_polyline(a: Vec2, apex: Vec2, b: Vec2, rho: f64) -> Vec { // Samples by how far the curve bows from its chord (small text curves need few). let chord = b - a; @@ -378,6 +396,30 @@ mod tests { } } + #[test] + fn conic_rho_from_point_round_trips_through_the_midpoint() { + let (a, apex, b) = (Vec2::new(0.0, 0.0), Vec2::new(20.0, 40.0), Vec2::new(40.0, 0.0)); + for rho in [0.1, 0.3, 0.5, 0.7, 0.9] { + let mid_point = conic_point(a, apex, b, rho, 0.5); + let back = conic_rho_from_point(a, apex, b, mid_point); + assert!((back - rho).abs() < 1e-9, "rho {rho} back {back}"); + } + // A point off the apex-chord-midpoint segment is projected onto it, not just measured + // as a raw distance: well off to the side at the same height as a known rho still + // recovers that rho. + let on_axis = conic_point(a, apex, b, 0.6, 0.5); + let off_axis = on_axis + Vec2::new(15.0, 0.0); + assert!((conic_rho_from_point(a, apex, b, off_axis) - 0.6).abs() < 1e-9); + // Degenerate apex (on the chord's midpoint): no segment to project onto, so the + // fallback is a parabola rather than a division by zero. + let mid = (a + b) * 0.5; + assert_eq!(conic_rho_from_point(a, mid, b, Vec2::new(100.0, 100.0)), 0.5); + // Always stays a valid, usable conic even for points far past either end. + let far = mid + (apex - mid) * 50.0; + let r = conic_rho_from_point(a, apex, b, far); + assert!(r > 0.0 && r < 1.0); + } + #[test] fn control_spline_is_clamped_and_conic_hits_its_ends() { let p = [Vec2::new(0.0, 0.0), Vec2::new(0.0, 10.0), Vec2::new(10.0, 10.0), Vec2::new(10.0, 0.0)]; diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 8d37bd55..70943b58 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -37,8 +37,8 @@ mod union_find; mod wire; pub use curves::{ - MAX_SPLINE_POINTS, bspline_beziers, conic_point, ellipse_point, end_curvature, fit_beziers, fit_beziers_mode, spline_end_tangent, - spline_end_tangent_mode, spline_point, spline_point_mode, spline_polyline, spline_polyline_mode, + MAX_SPLINE_POINTS, bspline_beziers, conic_point, conic_rho_from_point, ellipse_point, end_curvature, fit_beziers, fit_beziers_mode, + spline_end_tangent, spline_end_tangent_mode, spline_point, spline_point_mode, spline_polyline, spline_polyline_mode, }; pub use decompose::Budget; pub use dims::{DimFrame, DimLayout, chain_centres, default_text, dim_frame, dim_layout, encode_text}; diff --git a/crates/ui-egui/src/sketch_dims.rs b/crates/ui-egui/src/sketch_dims.rs index 24cf178a..b5db6e8c 100644 --- a/crates/ui-egui/src/sketch_dims.rs +++ b/crates/ui-egui/src/sketch_dims.rs @@ -43,6 +43,7 @@ pub fn boxes_for(cmd: &str, n: usize) -> Vec { ("sketch.arc.center_point", 2) => vec![b("Sweep", Angle)], ("sketch.polygon.inscribed" | "sketch.polygon.circumscribed", 1) => vec![b("Radius", Length), b("Sides", Unitless)], ("sketch.slot.center_to_center" | "sketch.slot.overall", 1) => vec![b("Length", Length)], + ("sketch.conic", 2) => vec![b("Rho", Unitless)], ("sketch.slot.center_to_center" | "sketch.slot.overall", 2) => vec![b("Width", Length)], _ => Vec::new(), } @@ -131,7 +132,7 @@ pub fn axis_aligned(a: Vec2, b: Vec2) -> Option { /// The value each box shows while it follows the cursor, and where it sits (sketch coordinates). /// `px` is the size of a screen pixel in sketch units: boxes sit a little off the segment they /// measure so they don't cover it. -fn live(t: &Tool, e: Vec2, px: f64) -> Vec<(f64, Vec2)> { +fn live(app: &SolveApp, t: &Tool, e: Vec2, px: f64) -> Vec<(f64, Vec2)> { let Some(a) = t.pts.last().map(|p| p.0) else { return Vec::new() }; let first = t.pts.first().map(|p| p.0).unwrap_or(a); let off = 20.0 * px; @@ -162,6 +163,16 @@ fn live(t: &Tool, e: Vec2, px: f64) -> Vec<(f64, Vec2)> { let a0 = (a - first).angle(); vec![(((e - first).angle() - a0).rem_euclid(std::f64::consts::TAU), e)] } + ("sketch.conic", 2) => { + // `rho` is the curve's fullness, not a distance: the conic passes through + // `chord midpoint + rho * (apex - chord midpoint)`, so the box sits on that point + // (the part of the curve the value moves) with the default parabola's 0.5 shown + // until a value is typed. + let rho = locked(app, t, "Rho").unwrap_or(0.5); + let mid = (first + a) * 0.5; + let d = e - mid; + vec![(rho, mid + d * rho + dir_or_x(d).perp() * off)] + } ("sketch.slot.center_to_center" | "sketch.slot.overall", 2) => { let dir = dir_or_x(a - first); vec![(2.0 * dir.cross(e - first).abs(), e)] @@ -170,9 +181,15 @@ fn live(t: &Tool, e: Vec2, px: f64) -> Vec<(f64, Vec2)> { } } -fn fmt(app: &SolveApp, kind: ValueKind, v: f64) -> String { +fn fmt(app: &SolveApp, label: &str, kind: ValueKind, v: f64) -> String { match kind { ValueKind::Angle => format!("{:.1} deg", v.to_degrees()), + // A count rounds to a whole number; a ratio keeps its decimals (0.5, not 1). + ValueKind::Unitless if label == "Rho" => { + let text = format!("{v:.3}"); + let text = text.trim_end_matches('0').trim_end_matches('.'); + if text.is_empty() { "0".to_string() } else { text.to_string() } + } ValueKind::Unitless => format!("{}", v.round()), ValueKind::Length => crate::prefs::show_mm_bare(app, v), } @@ -213,7 +230,7 @@ pub fn dimensions(t: &Tool) -> Value { .dim_history .iter() .chain(&t.dims) - .filter(|dimension| dimension.locked && dimension.label != "Sides") + .filter(|dimension| dimension.locked && !matches!(dimension.label, "Sides" | "Rho")) .map(|dimension| (dimension.label.to_ascii_lowercase(), json!(dimension.value))) .collect::>(); Value::Object(values) @@ -235,6 +252,8 @@ pub fn validate(app: &SolveApp, t: &Tool) -> Result<(), String> { "Sweep" => return Err("Sweep must lie strictly between -360 and 360 degrees, and cannot be zero".into()), "Sides" if (3.0..=200.0).contains(&value) && value.fract() == 0.0 => {} "Sides" => return Err("Sides must be an integer from 3 to 200".into()), + "Rho" if value > 1e-6 && value < 1.0 - 1e-6 => {} + "Rho" => return Err("Rho must lie strictly between 0 and 1 (below 0.5 an ellipse, 0.5 a parabola, above 0.5 a hyperbola)".into()), _ if value > 1e-9 => {} _ => return Err(format!("{} must be positive", dimension.label)), } @@ -280,7 +299,7 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option let h = t.hover.as_ref().map(|x| x.0)?; let e = effective(app, t, h); let px = 2.0 * app.cam.half_height() / f64::from(proj.rect.height().max(1.0)); - let lv = live(t, e, px); + let lv = live(app, t, e, px); let focus = std::mem::take(&mut t.dims_focus); // Egui processes ordinary Tab navigation before these floating text edits are drawn. // Remember the box that owned the keyboard and restore a focus within this tool after @@ -310,7 +329,7 @@ fn boxes(app: &SolveApp, ui: &mut egui::Ui, proj: &Proj, t: &mut Tool) -> Option for (i, b) in t.dims.iter_mut().enumerate() { let Some((v, at)) = lv.get(i).copied() else { continue }; if !b.locked { - b.value = fmt(app, b.kind, v); + b.value = fmt(app, b.label, b.kind, v); } let Some(sp) = proj.to_screen(plane.to_world(at)) else { continue }; let id = egui::Id::new(("sc_dim", i)); diff --git a/crates/ui-egui/src/sketch_dims_tests.rs b/crates/ui-egui/src/sketch_dims_tests.rs index 33b9b334..2920f8d3 100644 --- a/crates/ui-egui/src/sketch_dims_tests.rs +++ b/crates/ui-egui/src/sketch_dims_tests.rs @@ -147,3 +147,63 @@ fn inch_live_lengths_use_design_units_and_typed_four_digits_commit_in_inches() { let sk = &st.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch; assert!(sk.constraints.iter().any(|c| c.kind.value().is_some_and(|v| (v - 31.3563).abs() < 1e-6))); } + +/// A conic's fullness is a typed value as well as a drag. The Rho box takes the keyboard once +/// the chord is down, shows the parabola the command would otherwise default to, and the value +/// typed into it reaches the command when the apex is placed. +#[test] +fn a_typed_rho_shapes_the_conic_whose_apex_is_placed_next() { + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + h.step(&json!({"start": "sketch.conic"})).unwrap(); + h.step(&json!({"click": {"world": [0, 0, 0]}})).unwrap(); + h.step(&json!({"move": {"world": [40, 0, 0]}})).unwrap(); + // One point down is a chord with one end: there is no shape to put a number on yet. + assert!(h.app.tool.as_ref().unwrap().dims.is_empty()); + h.step(&json!({"click": {"world": [40, 0, 0]}})).unwrap(); + h.step(&json!({"move": {"world": [20, 20, 0]}})).unwrap(); + assert_focus(&h, 0); + let dims = &h.app.tool.as_ref().unwrap().dims; + assert_eq!(dims.iter().map(|d| d.label).collect::>(), ["Rho"]); + assert_eq!(dims[0].value, "0.5", "the live box reads as a ratio, not as a rounded count"); + assert!(!dims[0].locked); + + type_value(&mut h, "0.8"); + assert!(h.app.tool.as_ref().unwrap().dims[0].locked); + h.step(&json!({"key": "Enter"})).unwrap(); + + let state = h.app.session.world_state(); + let sketch = &state.sketch(h.app.session.active_sketch.unwrap()).unwrap().sketch; + let (apex, rho) = sketch + .curves + .iter() + .find_map(|curve| match curve.kind { + solidcraft_engine::sketch::CurveKind::Conic { apex, rho, .. } => Some((apex, rho)), + _ => None, + }) + .expect("the typed value placed a conic"); + assert!((rho - 0.8).abs() < 1e-9, "typed rho reached the command: {rho}"); + // The apex lands where the cursor was, within the pixel the cursor was converted through. + assert!(sketch.point(apex).unwrap().dist(solidcraft_engine::geom::Vec2::new(20.0, 20.0)) < 0.5, "{:?}", sketch.point(apex)); +} + +/// A rho the command would refuse stays in its box to be corrected, and places nothing. +#[test] +fn a_rho_outside_zero_to_one_is_refused_before_it_reaches_the_command() { + let mut h = Harness::new(); + h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); + h.step(&json!({"start": "sketch.conic"})).unwrap(); + h.step(&json!({"click": {"world": [0, 0, 0]}})).unwrap(); + h.step(&json!({"click": {"world": [40, 0, 0]}})).unwrap(); + h.step(&json!({"move": {"world": [20, 20, 0]}})).unwrap(); + type_value(&mut h, "5"); + h.step(&json!({"key": "Enter"})).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(), "a refused rho must place nothing"); + let tool = h.app.tool.as_ref().expect("the tool stays active for a correction"); + assert_eq!(tool.pts.len(), 2, "the chord stays down"); + assert_eq!(tool.dims.iter().map(|d| d.value.as_str()).collect::>(), ["5"], "the refused value stays in its box"); + assert!(h.app.status.as_ref().is_some_and(|(_, _, error)| *error), "the refusal is reported"); +} diff --git a/crates/ui-egui/src/sketch_display_preview_tests.rs b/crates/ui-egui/src/sketch_display_preview_tests.rs index 0fc780c2..adc9f32b 100644 --- a/crates/ui-egui/src/sketch_display_preview_tests.rs +++ b/crates/ui-egui/src/sketch_display_preview_tests.rs @@ -38,32 +38,53 @@ fn screen_error(paths: &[Vec], point: Vec3, proj: &Proj, ppp: f32) -> f32 #[test] fn pixel_preview_temporary_curves_match_committed_geometry_and_sampling() { - let cases: Vec<(&str, Vec<[f64; 2]>, [f64; 2], Option, Value)> = vec![ - ("sketch.circle.center", vec![[0., 0.]], [1., 0.], None, json!({"center":[0,0],"radius":1})), - ("sketch.circle.two_point", vec![[-1., 0.]], [1., 0.], None, json!({"p0":[-1,0],"p1":[1,0]})), - ("sketch.circle.three_point", vec![[1., 0.], [-1., 0.]], [0., 1.], None, json!({"p0":[1,0],"p1":[-1,0],"p2":[0,1]})), - ("sketch.arc.three_point", vec![[1., 0.], [-1., 0.]], [0., -1.], None, json!({"start":[1,0],"end":[-1,0],"through":[0,-1]})), + let cases: Vec<(&str, Vec<[f64; 2]>, [f64; 2], Option, Option, Value)> = vec![ + ("sketch.circle.center", vec![[0., 0.]], [1., 0.], None, None, json!({"center":[0,0],"radius":1})), + ("sketch.circle.two_point", vec![[-1., 0.]], [1., 0.], None, None, json!({"p0":[-1,0],"p1":[1,0]})), + ("sketch.circle.three_point", vec![[1., 0.], [-1., 0.]], [0., 1.], None, None, json!({"p0":[1,0],"p1":[-1,0],"p2":[0,1]})), + ("sketch.arc.three_point", vec![[1., 0.], [-1., 0.]], [0., -1.], None, None, json!({"start":[1,0],"end":[-1,0],"through":[0,-1]})), ( "sketch.arc.center_point", vec![[0., 0.], [1., 0.]], [0., -1.], Some(-std::f64::consts::FRAC_PI_2), + None, json!({"center":[0,0],"start":[1,0],"sweep":-90}), ), - ("sketch.ellipse", vec![[0., 0.], [1., 0.]], [0., 0.05], None, json!({"center":[0,0],"major":[1,0],"minor":[0,0.05]})), - ("sketch.spline.fit_point", vec![[0., 0.], [0.3, 0.8], [0.6, -0.8]], [1., 0.], None, json!({"points":[[0,0],[0.3,0.8],[0.6,-0.8],[1,0]]})), + ("sketch.ellipse", vec![[0., 0.], [1., 0.]], [0., 0.05], None, None, json!({"center":[0,0],"major":[1,0],"minor":[0,0.05]})), + ("sketch.conic", vec![[0., 0.], [1., 0.]], [0.5, 0.5], None, None, json!({"start":[0,0],"end":[1,0],"apex":[0.5,0.5]})), + // A typed Rho previews the curve the third click is about to commit, not the default + // parabola: an ellipse below 0.5, a hyperbola above it. + ("sketch.conic", vec![[0., 0.], [1., 0.]], [0.5, 0.5], None, Some(0.2), json!({"start":[0,0],"end":[1,0],"apex":[0.5,0.5],"rho":0.2})), + ("sketch.conic", vec![[0., 0.], [1., 0.]], [0.5, 0.5], None, Some(0.85), json!({"start":[0,0],"end":[1,0],"apex":[0.5,0.5],"rho":0.85})), + ( + "sketch.spline.fit_point", + vec![[0., 0.], [0.3, 0.8], [0.6, -0.8]], + [1., 0.], + None, + None, + json!({"points":[[0,0],[0.3,0.8],[0.6,-0.8],[1,0]]}), + ), ( "sketch.spline.control_point_5", vec![[0., 0.], [0.2, 0.9], [0.4, -0.9], [0.6, 0.9], [0.8, -0.9]], [1., 0.], None, + None, json!({"points":[[0,0],[0.2,0.9],[0.4,-0.9],[0.6,0.9],[0.8,-0.9],[1,0]]}), ), - ("sketch.spline.fit_point", vec![[0., 0.], [1., 0.], [0.5, 1.]], [0., 0.], None, json!({"points":[[0,0],[1,0],[0.5,1]],"periodic":true})), + ( + "sketch.spline.fit_point", + vec![[0., 0.], [1., 0.], [0.5, 1.]], + [0., 0.], + None, + None, + json!({"points":[[0,0],[1,0],[0.5,1]],"periodic":true}), + ), ]; - for (command, points, hover, sweep, params) in cases { + for (command, points, hover, sweep, rho, params) in cases { let tool = tool(command, &points); - let preview = preview_curve(&tool, Vec2::new(hover[0], hover[1]), sweep).unwrap(); + let preview = preview_curve(&tool, Vec2::new(hover[0], hover[1]), sweep, rho).unwrap(); let mut h = Harness::new(); h.app.run("sketch.create", json!({"plane":"XY"})).unwrap(); h.app.run(command, params).unwrap(); @@ -106,7 +127,7 @@ fn pixel_preview_temporary_curves_match_committed_geometry_and_sampling() { #[test] fn pixel_preview_fully_offscreen_circle_is_clipped_without_false_connections() { - let curve = preview_curve(&tool("sketch.circle.center", &[[0., 0.]]), Vec2::X, None).unwrap(); + let curve = preview_curve(&tool("sketch.circle.center", &[[0., 0.]]), Vec2::X, None, None).unwrap(); let proj = projection(1000.0); // The viewport half diagonal is about 832px: a 1000px circle never enters it. assert!((proj.rect.width() * 0.5).hypot(proj.rect.height() * 0.5) < 1000.0); @@ -115,10 +136,10 @@ fn pixel_preview_fully_offscreen_circle_is_clipped_without_false_connections() { #[test] fn pixel_preview_rejects_degenerate_and_unbounded_input_without_changing_document() { - assert!(preview_curve(&tool("sketch.circle.center", &[[0., 0.]]), Vec2::new(f64::NAN, 0.), None).is_none()); - assert!(preview_curve(&tool("sketch.arc.three_point", &[[0., 0.], [1., 0.]]), Vec2::new(2., 0.), None).is_none()); + assert!(preview_curve(&tool("sketch.circle.center", &[[0., 0.]]), Vec2::new(f64::NAN, 0.), None, None).is_none()); + assert!(preview_curve(&tool("sketch.arc.three_point", &[[0., 0.], [1., 0.]]), Vec2::new(2., 0.), None, None).is_none()); let points = vec![[0., 0.]; 500]; - assert!(preview_curve(&tool("sketch.spline.fit_point", &points), Vec2::X, None).is_none()); + assert!(preview_curve(&tool("sketch.spline.fit_point", &points), Vec2::X, None, None).is_none()); } #[test] diff --git a/crates/ui-egui/src/sketch_tools.rs b/crates/ui-egui/src/sketch_tools.rs index 5a6c2555..57590aec 100644 --- a/crates/ui-egui/src/sketch_tools.rs +++ b/crates/ui-egui/src/sketch_tools.rs @@ -406,7 +406,7 @@ pub fn hint(id: &str) -> Option { "sketch.line.midpoint" => "Midpoint line: click the middle, then an end", "sketch.arc.tangent" => "Tangent arc: click the end of a line or arc, then the arc's end", "sketch.ellipse" => "Ellipse: click the centre, the end of the major axis, then a point on it", - "sketch.conic" => "Conic: click the start, the end, then the apex", + "sketch.conic" => "Conic: click the start, the end, then the apex; type Rho to shape it (0.5 is a parabola)", "sketch.slot.center_point" => "Slot: click the centre, an arc centre, then the width", "sketch.slot.arc_three_point" => "Arc slot: click start, a point on the arc, the end, then the width", "sketch.slot.arc_center" => "Arc slot: click the centre, the start, the end, then the width", @@ -688,7 +688,7 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) { let _ = app.run(cmd, json!({"curve": id, "at": xy(p)})); } } - Mode::Points(n) => { + Mode::Points(_) => { if periodic_start_snap(app, tool, proj, pos).is_some() { // Only this deliberate click selects periodic interpolation. A // right-click finish and all previously saved splines stay natural. @@ -700,11 +700,7 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) { return; } let Some(p) = at else { return }; - tool.pts.push(p); - if n > 0 && tool.pts.len() >= n { - run_points(app, tool); - tool.pts.clear(); - } + place_point(app, tool, p); } Mode::Entities(n) => { if cmd == "sketch.fillet" || cmd.starts_with("sketch.chamfer.") { @@ -847,6 +843,22 @@ pub fn on_click(app: &mut SolveApp, tool: &mut Tool, proj: &Proj, pos: Pos2) { } } +/// Put down the next point of a point tool: a click, or Enter in one of its value boxes. The +/// last point runs the command. Typed values are checked first, so a rejected one stays in its +/// box to be corrected rather than reaching the command. +pub(crate) fn place_point(app: &mut SolveApp, tool: &mut Tool, p: (Vec2, Option)) { + let Some(Mode::Points(n)) = mode(tool.cmd) else { return }; + if let Err(error) = crate::sketch_dims::validate(app, tool) { + app.set_status(error, true); + return; + } + tool.pts.push(p); + if n > 0 && tool.pts.len() >= n { + run_points(app, tool); + tool.pts.clear(); + } +} + fn run_points(app: &mut SolveApp, t: &Tool) { let p = &t.pts; let width = |a: Vec2, b: Vec2, c: Vec2| -> f64 { 2.0 * (b - a).normalized().map(|d| d.cross(c - a).abs()).unwrap_or(1.0) }; @@ -854,7 +866,11 @@ fn run_points(app: &mut SolveApp, t: &Tool) { ("sketch.line.midpoint", [m, e]) => json!({"mid": arg(m), "end": xy(e.0)}), ("sketch.arc.tangent", [s, e]) => json!({"start": arg(s), "end": xy(e.0)}), ("sketch.ellipse", [c, m, q]) => json!({"center": xy(c.0), "major": xy(m.0), "minor": xy(q.0)}), - ("sketch.conic", [a, b, x]) => json!({"start": arg(a), "end": arg(b), "apex": arg(x)}), + ("sketch.conic", [a, b, x]) => { + // The typed Rho box, or the parabola the command defaults to. + let rho = crate::sketch_dims::locked_value(app, t, "Rho").unwrap_or(0.5); + json!({"start": arg(a), "end": arg(b), "apex": arg(x), "rho": rho}) + } ("sketch.slot.center_point", [c, e, w]) => json!({"center": xy(c.0), "end": xy(e.0), "width": width(c.0, e.0, w.0).max(1e-3)}), ("sketch.slot.arc_three_point", [a, th, b, w]) => { json!({"start": xy(a.0), "through": xy(th.0), "end": xy(b.0), "width": (2.0 * w.0.dist(b.0)).max(1e-3)}) @@ -982,20 +998,28 @@ 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 faint = Stroke::new(1.0, tk.rubber_band.gamma_multiply(0.6)); + let mode = crate::sketch_palette::mode(app); 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)), - ); + crate::tools::paint_sketch_path(&[ss.plane.to_world(w[0].0), ss.plane.to_world(w[1].0)], mode, painter, proj, faint); } + // Placing a conic's apex: the curve is the preview, and the two tangent legs it is built + // from are a faint guide. A full-strength cursor rubber band here read as a straight line + // being drawn rather than as a curve being shaped. + let legs = t.cmd == "sketch.conic" && t.pts.len() == 2; 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); + let hw = ss.plane.to_world(*h); + if legs { + if let Some(first) = t.pts.first() { + crate::tools::paint_sketch_path(&[ss.plane.to_world(first.0), hw], mode, painter, proj, faint); + } + crate::tools::paint_sketch_path(&[ss.plane.to_world(last.0), hw], mode, painter, proj, faint); + } else { + crate::tools::paint_sketch_path(&[ss.plane.to_world(last.0), hw], mode, painter, proj, stroke); + } } if let Some((h, _)) = t.hover.as_ref() - && let Some(curve) = crate::tools::preview_curve(t, *h, None) + && let Some(curve) = crate::tools::preview_curve(t, *h, None, crate::sketch_dims::locked_value(app, t, "Rho")) { crate::tools::paint_preview_curve(curve, &ss.plane, painter, proj, stroke, crate::sketch_palette::mode(app)); } diff --git a/crates/ui-egui/src/tools.rs b/crates/ui-egui/src/tools.rs index 37b3c442..98c34d9c 100644 --- a/crates/ui-egui/src/tools.rs +++ b/crates/ui-egui/src/tools.rs @@ -263,6 +263,11 @@ pub fn on_click(app: &mut SolveApp, proj: &Proj, pos: Pos2) { /// the typed values as dimension constraints). pub fn place(app: &mut SolveApp, p: (Vec2, Option)) { let Some(mut tool) = app.tool.take() else { return }; + if tool.kind == Kind::Ext { + crate::sketch_tools::place_point(app, &mut tool, p); + app.tool = Some(tool); + return; + } let Kind::Draw(n) = tool.kind else { app.tool = Some(tool); return; @@ -530,7 +535,7 @@ pub(crate) fn paint_curve( /// Temporary analytic geometry uses the same exact span extraction as committed curves. /// It never solves constraints or modifies the document. -pub(crate) fn preview_curve(t: &Tool, hover: Vec2, sweep: Option) -> Option { +pub(crate) fn preview_curve(t: &Tool, hover: Vec2, sweep: Option, rho: Option) -> Option { use solidcraft_engine::sketch::{CurveKind, Sketch}; if !hover.is_finite() || t.pts.len() >= solidcraft_engine::sketch::MAX_SPLINE_POINTS || t.pts.iter().any(|p| !p.0.is_finite()) { return None; @@ -588,6 +593,18 @@ pub(crate) fn preview_curve(t: &Tool, hover: Vec2, sweep: Option) -> Option let m = sk.add_point(*m, None).ok()?; sk.add_curve(CurveKind::Ellipse { c, m, r }, None).ok()?; } + ("sketch.conic", [a, b]) => { + // Positioning the apex (the third click): the live conic through the cursor at the + // typed `rho` (or the default parabola's 0.5), so the curve it is about to become is + // on screen while it is being placed, and a typed `rho` reshapes it before the + // click. Without this the tool showed only the straight start-to-end, end-to-cursor + // rubber band every tool draws while it has points still to place. + let rho = rho.filter(|r| r.is_finite() && *r > 1e-6 && *r < 1.0 - 1e-6).unwrap_or(0.5); + let a = sk.add_point(*a, None).ok()?; + let b = sk.add_point(*b, None).ok()?; + let apex = sk.add_point(hover, None).ok()?; + sk.add_curve(CurveKind::Conic { a, b, apex, rho }, None).ok()?; + } ("sketch.spline.fit_point" | "sketch.spline.control_point" | "sketch.spline.control_point_5", points) if !points.is_empty() => { let periodic = t.cmd == "sketch.spline.fit_point" && points.len() >= 3 && points.first() == Some(&hover); let mut indices = points.iter().map(|p| sk.add_point(*p, None).ok()).collect::>>()?; @@ -679,7 +696,7 @@ pub fn preview(app: &SolveApp, t: &Tool, painter: &egui::Painter, proj: &Proj) { } } - if let Some(curve) = preview_curve(t, h, crate::sketch_dims::locked_value(app, t, "Sweep")) { + if let Some(curve) = preview_curve(t, h, crate::sketch_dims::locked_value(app, t, "Sweep"), crate::sketch_dims::locked_value(app, t, "Rho")) { paint_preview_curve(curve, &ss.plane, painter, proj, stroke, crate::sketch_palette::mode(app)); return; } diff --git a/crates/ui-egui/tests/scenarios.rs b/crates/ui-egui/tests/scenarios.rs index c6b79c4f..e6a5b618 100644 --- a/crates/ui-egui/tests/scenarios.rs +++ b/crates/ui-egui/tests/scenarios.rs @@ -66,6 +66,78 @@ fn angle_dimension_both_interactive_workflows() { } } +/// The conic tool still places a curve in three clicks (start, end, apex), the way it always +/// has, defaulting to a parabola (`rho` 0.5). A further drag on the curve itself (not on one of +/// its three points) reads `rho` off where the drag lands between the chord's midpoint and the +/// apex, so an ellipse or a hyperbola is a drag away rather than a fourth click away. The apex +/// stays an ordinary, separately draggable point: dragging it slides the curve's tangent point +/// and leaves `rho` alone. Both drags are tried after leaving the tool with Escape, the way a +/// person reshapes anything else they have just drawn (the tool is sticky, placing more conics +/// on further clicks, the way the ellipse and slot tools already are too). +#[test] +fn dragging_a_conic_after_the_tool_sets_rho_and_its_apex_stays_separately_draggable() { + use serde_json::json; + let mut h = Harness::new(); + h.run(&json!([ + {"call": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}}}, + {"call": "ui.view", "params": {"view": "top"}}, + {"start": "sketch.conic"}, + {"click": {"sketch": [0, 0]}}, + {"click": {"sketch": [40, 0]}}, + {"click": {"sketch": [20, 20]}}, + {"key": "Escape"}, + {"expect": {"errors": 0}} + ])) + .unwrap(); + + let sk = h.app.session.model.state().sketches[0].sketch.clone(); + let (ci, apex_idx, rho) = sk + .curves + .iter() + .enumerate() + .find_map(|(i, c)| match c.kind { + solidcraft_engine::sketch::CurveKind::Conic { apex, rho, .. } => Some((i, apex, rho)), + _ => None, + }) + .expect("the three clicks created a conic"); + assert!((rho - 0.5).abs() < 1e-6, "default rho {rho}"); + let apex_before = sk.point(apex_idx).expect("apex point resolves"); + assert!(apex_before.dist(solidcraft_engine::geom::Vec2::new(20.0, 20.0)) < 1e-6); + + // Grabbed on the curve itself, at its default-rho midpoint (20, 10): the chord's midpoint + // is (20, 0), the apex (20, 20), so (20, 14) is 0.7 of the way from one to the other. + h.run(&json!([ + {"drag": [{"sketch": [20, 10]}, {"sketch": [20, 14]}], "steps": 4}, + {"expect": {"errors": 0}} + ])) + .unwrap(); + let sk_after_rho_drag = h.app.session.model.state().sketches[0].sketch.clone(); + let rho_after = match sk_after_rho_drag.curves[ci].kind { + solidcraft_engine::sketch::CurveKind::Conic { rho, .. } => rho, + ref k => panic!("not a conic: {k:?}"), + }; + assert!((rho_after - 0.7).abs() < 0.02, "rho after drag {rho_after}"); + let apex_after_rho_drag = sk_after_rho_drag.point(apex_idx).expect("apex point resolves"); + assert!(apex_before.dist(apex_after_rho_drag) < 1e-6, "the apex moved on a curve-body drag"); + + // Grabbed on the apex point itself: it slides, and rho is left as the previous drag set it. + h.run(&json!([ + {"drag": [{"sketch": [20, 20]}, {"sketch": [10, 30]}], "steps": 4}, + {"expect": {"errors": 0}} + ])) + .unwrap(); + let sk_after_apex_drag = h.app.session.model.state().sketches[0].sketch.clone(); + let apex_after = sk_after_apex_drag.point(apex_idx).expect("apex point still resolves"); + assert!(apex_before.dist(apex_after) > 1.0, "apex did not move: still at {apex_after:?}"); + let rho_final = match sk_after_apex_drag.curves[ci].kind { + solidcraft_engine::sketch::CurveKind::Conic { rho, .. } => rho, + ref k => panic!("not a conic: {k:?}"), + }; + assert!((rho_final - 0.7).abs() < 0.02, "rho changed on an apex drag: {rho_final}"); + // The same curve, reshaped in place (not replaced): the id survives both drags. + assert_eq!(sk_after_apex_drag.curves.get(ci).map(|c| &c.id), sk.curves.get(ci).map(|c| &c.id)); +} + #[test] fn home_tab_and_file_recent_open_designs() { use serde_json::json;