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

+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;
}