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:
1 parent
9b5e39844a
commit
aae70053ee
10 files changed
+349
-47
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)];
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user