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.
This commit is contained in:
William Dunn authored and GitHub committed 2026-10-13 11:47:28 -04:00
1 parent 9b5e39844a
commit aae70053ee
10 files changed
+349 -47

No files matched your search

+28 -7
View File
@@ -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<Item = usize> + '_ {
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();
+26
View File
@@ -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();
+42
View File
@@ -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<Vec2> {
// 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)];
+2 -2
View File
@@ -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};
+24 -5
View File
@@ -43,6 +43,7 @@ pub fn boxes_for(cmd: &str, n: usize) -> Vec<DimBox> {
("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<bool> {
/// 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::<serde_json::Map<_, _>>();
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));
+60
View File
@@ -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::<Vec<_>>(), ["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::<Vec<_>>(), ["5"], "the refused value stays in its box");
assert!(h.app.status.as_ref().is_some_and(|(_, _, error)| *error), "the refusal is reported");
}
@@ -38,32 +38,53 @@ fn screen_error(paths: &[Vec<Vec3>], 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<f64>, 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<f64>, Option<f64>, 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]
+41 -17
View File
@@ -406,7 +406,7 @@ pub fn hint(id: &str) -> Option<String> {
"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<String>)) {
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));
}
+19 -2
View File
@@ -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<String>)) {
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<f64>) -> Option<solidcraft_engine::sketch::Sketch> {
pub(crate) fn preview_curve(t: &Tool, hover: Vec2, sweep: Option<f64>, rho: Option<f64>) -> Option<solidcraft_engine::sketch::Sketch> {
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<f64>) -> 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::<Option<Vec<_>>>()?;
@@ -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;
}
+72
View File
@@ -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;