Sketch free-form curves: Ellipse (centre, major axis end, minor radius), Fit Point and Control Point (degree 3 and 5) Splines, Conic Curve (rho); solved like other curves (points on them, tangency at shared ends, drag fit points), profiled as polylines; mirror/pattern/move/scale copy them; Tangent Arc continues splines and conics

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
bherbruckandClaude Opus 5.5 committed 2026-10-07 22:35:04 +00:00
1 parent 956492c993
commit 8d7a0cf3d0
14 files changed
+708 -100

No files matched your search

+3
View File
@@ -69,6 +69,9 @@ fn sketch_json(s: &Session, id: u64) -> Option<Value> {
CurveKind::Line { a, b } => json!({"id": c.id, "type": "line", "start": pid(a), "end": pid(b), "start_at": sk.point(a), "end_at": sk.point(b), "construction": c.construction, "fully_constrained": det}),
CurveKind::Circle { c: cc, r } => json!({"id": c.id, "type": "circle", "center": pid(cc), "center_at": sk.point(cc), "radius": r, "construction": c.construction, "fully_constrained": det}),
CurveKind::Arc { c: cc, a, b } => json!({"id": c.id, "type": "arc", "center": pid(cc), "start": pid(a), "end": pid(b), "center_at": sk.point(cc), "start_at": sk.point(a), "end_at": sk.point(b), "radius": sk.radius(i), "construction": c.construction, "fully_constrained": det}),
CurveKind::Ellipse { c: cc, m, r } => json!({"id": c.id, "type": "ellipse", "center": pid(cc), "major": pid(m), "center_at": sk.point(cc), "major_at": sk.point(m), "minor_radius": r, "construction": c.construction, "fully_constrained": det}),
CurveKind::Spline { ref pts, control, degree } => json!({"id": c.id, "type": "spline", "points": pts.iter().map(|q| pid(*q)).collect::<Vec<_>>(), "points_at": pts.iter().map(|q| sk.point(*q)).collect::<Vec<_>>(), "control": control, "degree": degree, "construction": c.construction, "fully_constrained": det}),
CurveKind::Conic { a, b, apex, rho } => json!({"id": c.id, "type": "conic", "start": pid(a), "end": pid(b), "apex": pid(apex), "start_at": sk.point(a), "end_at": sk.point(b), "apex_at": sk.point(apex), "rho": rho, "construction": c.construction, "fully_constrained": det}),
};
if let (Some(l), Some(o)) = (&c.link, v.as_object_mut()) {
o.insert("link".into(), json!(l));
+2 -7
View File
@@ -897,7 +897,7 @@ fn c_symmetry(s: &mut Session, p: &Value) -> Result<Value> {
fn round_center(sk: &Sketch, c: usize) -> Option<usize> {
match sk.curves.get(c)?.kind {
CurveKind::Circle { c, .. } | CurveKind::Arc { c, .. } => Some(c),
CurveKind::Line { .. } => None,
_ => None,
}
}
@@ -910,12 +910,7 @@ fn c_fix(s: &mut Session, p: &Value) -> Result<Value> {
vec![i]
} else {
let c = sk.curve_index(r).ok_or_else(|| bad(cmd, format!("unknown entity `{r}`")))?;
match sk.curves.get(c).map(|c| c.kind.clone()) {
Some(CurveKind::Line { a, b }) => vec![a, b],
Some(CurveKind::Circle { c, .. }) => vec![c],
Some(CurveKind::Arc { c, a, b }) => vec![c, a, b],
None => vec![],
}
sk.curves.get(c).map(|c| c.kind.point_ids()).unwrap_or_default()
};
let is_fixed = |sk: &Sketch, q: usize| sk.constraints.iter().any(|c| c.kind == ConstraintKind::Fix { p: q });
let all_fixed = pts.iter().all(|q| is_fixed(sk, *q));
+146 -1
View File
@@ -45,8 +45,133 @@ pub static COMMANDS: &[CommandSpec] =
.icon("arc_slot")
.enabled(in_sketch)
.params("center, start: centre arc start, end: direction of the centre arc end (counter-clockwise), width"),
CommandSpec::new("CircleElipse", "Ellipse", ellipse)
.at("SKETCH", "CREATE")
.icon("ellipse")
.enabled(in_sketch)
.params("center, major: end of the major axis [x,y], minor: a point the ellipse passes through on the minor side [x,y] | minor_radius"),
CommandSpec::new("DrawSpline", "Fit Point Spline", spline_fit)
.at("SKETCH", "CREATE")
.icon("spline")
.enabled(in_sketch)
.params("points: [[x,y] or point refs…] (2…500): the spline passes through them"),
CommandSpec::new("DrawCVMSpline3D", "Control Point Spline", spline_cv3)
.at("SKETCH", "CREATE")
.icon("spline_cv")
.enabled(in_sketch)
.params("points: control points (2…500), cubic"),
CommandSpec::new("DrawCVMSpline5D", "Control Point Spline (Degree 5)", spline_cv5)
.at("SKETCH", "CREATE")
.icon("spline_cv")
.enabled(in_sketch)
.params("points: control points (2…500), degree 5"),
CommandSpec::new("ConicCurveCmd", "Conic Curve", conic)
.at("SKETCH", "CREATE")
.icon("conic")
.enabled(in_sketch)
.params("start, end, apex: [x,y] or point refs; rho?: 0…1 (default 0.5: parabola)"),
];
fn ellipse(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "CircleElipse";
let (c, m) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "major")?);
let u = (m - c).normalized().ok_or_else(|| bad(cmd, "the major axis end must differ from the centre"))?;
let a = c.dist(m);
let r = match (num(p, "minor_radius"), p.get("minor").and_then(crate::params::vec2)) {
(Some(r), _) => r,
(None, Some(q)) => {
// Minor radius so the ellipse passes through q.
let d = q - c;
let (x, y) = (d.dot(u), d.cross(u).abs());
let k = 1.0 - (x / a).powi(2);
if k <= 1e-12 {
return Err(bad(cmd, "the minor point must lie within the major axis span"));
}
y / k.sqrt()
}
_ => return Err(bad(cmd, "needs `minor` [x,y] or `minor_radius`")),
};
if !(r > 1e-9 && r < 1e8) {
return Err(bad(cmd, "the minor radius must be positive"));
}
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let ci = sk.add_point(c, None)?;
let mi = sk.add_point(m, None)?;
let e = sk.add_curve(CurveKind::Ellipse { c: ci, m: mi, r }, None)?;
mark_construction(sk, &[e], p);
Ok((ids_of(sk, &[e]), Vec::new()))
})?;
Ok(result(out, info))
}
fn point_list(sk: &mut Sketch, p: &Value, cmd: &str) -> Result<Vec<usize>> {
let list = p.get("points").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`points` must be a list"))?;
if list.len() < 2 || list.len() > solvecraft_sketch::MAX_SPLINE_POINTS {
return Err(bad(cmd, "a spline needs 2 to 500 points"));
}
let mut out = Vec::new();
for v in list {
let a = super::sketch::parg(sk, v, cmd)?;
let i = match a.idx() {
Some(i) => i,
None => sk.add_point(a.pos(), None)?,
};
if out.last() == Some(&i) || out.last().and_then(|l| sk.point(*l)).is_some_and(|q| q.dist(a.pos()) < 1e-9) {
return Err(bad(cmd, "consecutive points must differ"));
}
out.push(i);
}
Ok(out)
}
fn spline(s: &mut Session, p: &Value, cmd: &str, control: bool, degree: u8) -> Result<Value> {
let p = &super::sketch_project::snap_points(s, p, cmd)?;
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let pts = point_list(sk, p, cmd)?;
let c = sk.add_curve(CurveKind::Spline { pts, control, degree }, None)?;
mark_construction(sk, &[c], p);
Ok((ids_of(sk, &[c]), Vec::new()))
})?;
Ok(result(out, info))
}
fn spline_fit(s: &mut Session, p: &Value) -> Result<Value> {
spline(s, p, "DrawSpline", false, 3)
}
fn spline_cv3(s: &mut Session, p: &Value) -> Result<Value> {
spline(s, p, "DrawCVMSpline3D", true, 3)
}
fn spline_cv5(s: &mut Session, p: &Value) -> Result<Value> {
spline(s, p, "DrawCVMSpline5D", true, 5)
}
fn conic(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ConicCurveCmd";
let rho = num(p, "rho").unwrap_or(0.5);
if !(rho > 1e-6 && rho < 1.0 - 1e-6) {
return Err(bad(cmd, "`rho` must be between 0 and 1"));
}
let p = &super::sketch_project::snap_points(s, p, cmd)?;
let (out, info) = edit(s, p, cmd, false, |sk, _| {
let mut idx = Vec::new();
for k in ["start", "end", "apex"] {
let a = req_parg(sk, p, k, cmd)?;
idx.push(match a.idx() {
Some(i) => i,
None => sk.add_point(a.pos(), None)?,
});
}
let (a, b, x) = (idx[0], idx[1], idx[2]);
if a == b || a == x || b == x {
return Err(bad(cmd, "start, end and apex must differ"));
}
let c = sk.add_curve(CurveKind::Conic { a, b, apex: x, rho }, None)?;
mark_construction(sk, &[c], p);
Ok((ids_of(sk, &[c]), Vec::new()))
})?;
Ok(result(out, info))
}
fn midpoint_line(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SketchMidpointLine";
let end = req_vec2(cmd, p, "end")?;
@@ -92,7 +217,27 @@ fn leaving_dir(sk: &Sketch, ci: usize, pi: usize) -> Option<Vec2> {
None
}
}
CurveKind::Circle { .. } => None,
CurveKind::Spline { ref pts, control, .. } => {
let q: Vec<Vec2> = pts.iter().filter_map(|i| sk.point(*i)).collect();
if pts.last() == Some(&pi) {
solvecraft_sketch::spline_end_tangent(&q, control, true)
} else if pts.first() == Some(&pi) {
solvecraft_sketch::spline_end_tangent(&q, control, false).map(|t| -t)
} else {
None
}
}
CurveKind::Conic { a, b, apex, .. } => {
let x = sk.point(apex)?;
if pi == b {
(sk.point(b)? - x).normalized()
} else if pi == a {
(sk.point(a)? - x).normalized()
} else {
None
}
}
_ => None,
}
}
+16 -12
View File
@@ -212,6 +212,7 @@ fn split_at(sk: &mut Sketch, ci: usize, ts: &[f64]) -> Result<(Vec<usize>, Vec<u
push(sk, CurveKind::Arc { c, a: w[0], b: w[1] }, "a")?;
}
}
_ => return Err(bad("split", "not supported yet for ellipses, splines and conics")),
}
Ok((pieces, pts))
}
@@ -400,11 +401,7 @@ fn extend(s: &mut Session, p: &Value) -> Result<Value> {
}
fn uses(c: &Curve, p: usize) -> bool {
match c.kind {
CurveKind::Line { a, b } => a == p || b == p,
CurveKind::Circle { c, .. } => c == p,
CurveKind::Arc { c, a, b } => c == p || a == p || b == p,
}
c.kind.uses(p)
}
// ---------------------------------------------------------------------------------------------
@@ -631,7 +628,7 @@ fn offset_piece(sk: &Sketch, c: usize, forward: bool, d: f64) -> Option<Piece> {
let (s, e) = if forward { (at(pa), at(pb)) } else { (at(pb), at(pa)) };
Some(Piece::Arc(ci, cc, nr, s, e, forward))
}
CurveKind::Circle { .. } => None,
_ => None,
}
}
@@ -771,11 +768,8 @@ fn entities(sk: &Sketch, p: &Value, cmd: &str) -> Result<(Vec<usize>, Vec<usize>
fn involved_points(sk: &Sketch, cs: &[usize], ps: &[usize]) -> Vec<usize> {
let mut v: Vec<usize> = Vec::new();
for c in cs {
match sk.curves.get(*c).map(|c| &c.kind) {
Some(CurveKind::Line { a, b }) => v.extend([*a, *b]),
Some(CurveKind::Circle { c, .. }) => v.push(*c),
Some(CurveKind::Arc { c, a, b }) => v.extend([*c, *a, *b]),
None => {}
if let Some(k) = sk.curves.get(*c).map(|c| &c.kind) {
v.extend(k.point_ids());
}
}
v.extend(ps);
@@ -811,7 +805,17 @@ fn copy_through(
let mut made = Vec::new();
for c in cs {
let Some(cu) = sk.curves.get(*c).cloned() else { continue };
let mut kind = cu.kind.clone();
kind.map_points(&m);
let (kind, prefix) = match cu.kind {
CurveKind::Ellipse { r, .. } => {
if let CurveKind::Ellipse { r: slot, .. } = &mut kind {
*slot = r * scale_r;
}
(kind, "e")
}
CurveKind::Spline { .. } => (kind, "s"),
CurveKind::Conic { .. } => (kind, "k"),
CurveKind::Line { a, b } => (CurveKind::Line { a: m(a), b: m(b) }, "l"),
CurveKind::Circle { c, r } => (CurveKind::Circle { c: m(c), r: r * scale_r }, "c"),
CurveKind::Arc { c, a, b } => {
@@ -948,7 +952,7 @@ fn transform_in_place(sk: &mut Sketch, cs: &[usize], ps: &[usize], f: &dyn Fn(Ve
}
}
for c in cs {
if let Some(Curve { kind: CurveKind::Circle { r, .. }, .. }) = sk.curves.get_mut(*c) {
if let Some(Curve { kind: CurveKind::Circle { r, .. } | CurveKind::Ellipse { r, .. }, .. }) = sk.curves.get_mut(*c) {
*r *= k;
}
}
@@ -205,3 +205,58 @@ fn new_creation_tools() {
assert!(a.iter().any(|x| close(*x, pi * 10.0 * 2.0 + pi)), "{a:?}");
assert!(a.iter().any(|x| close(*x, pi * 5.0 * 2.0 + pi)), "{a:?}");
}
#[test]
fn ellipse_splines_conic() {
let pi = std::f64::consts::PI;
let mut s = new_sketch();
let e = run(&mut s, "CircleElipse", json!({"center": [0, 0], "major": [10, 0], "minor": [0, 4]}));
let eid = ids(&e["curves"])[0].clone();
let a = profiles(&s);
assert!((a[0] - pi * 40.0).abs() / (pi * 40.0) < 2e-3, "{a:?}");
// A point constrained onto the ellipse lands on it.
run(&mut s, "DrawPoint", json!({"point": [3, 5], "id": "q"}));
run(&mut s, "ConstraintCoincident", json!({"a": "q", "b": eid}));
let sk = sketch(&s);
let q = sk.point(sk.point_index("q").unwrap()).unwrap();
let poly = sk.polyline(sk.curve_index(&eid).unwrap());
let d = poly.windows(2).map(|w| {
let (a, b) = (w[0], w[1]);
let t = ((q - a).dot(b - a) / (b - a).len2()).clamp(0.0, 1.0);
q.dist(a + (b - a) * t)
});
assert!(d.fold(f64::INFINITY, f64::min) < 1e-6, "{q:?}");
// Fit spline closed by a line: one profile.
let sp = run(&mut s, "DrawSpline", json!({"points": [[100, 0], [110, 8], [120, 3], [130, 0]]}));
let sid = ids(&sp["curves"])[0].clone();
run(&mut s, "DrawPolyline", json!({"points": [format!("{sid}.end"), format!("{sid}.start")]}));
assert_eq!(profiles(&s).len(), 2);
// Moving a fit point reshapes the spline (it is a sketch point).
let sk = sketch(&s);
let si = sk.curve_index(&sid).unwrap();
let pts = sk.curves[si].kind.point_ids();
let pid = sk.points[pts[1]].id.clone();
run(&mut s, "sketch.move_point", json!({"point": pid, "to": [110, 12]}));
let sk = sketch(&s);
assert!(sk.polyline(si).iter().any(|q| q.dist(Vec2::new(110.0, 12.0)) < 1e-9));
// Control point splines (degree 3 and 5) and a conic.
run(&mut s, "DrawCVMSpline3D", json!({"points": [[0, 50], [0, 60], [10, 60], [10, 50]]}));
run(&mut s, "DrawCVMSpline5D", json!({"points": [[20, 50], [20, 60], [25, 65], [30, 60], [35, 62], [40, 50]]}));
let w = std::f64::consts::FRAC_1_SQRT_2;
let k = run(&mut s, "ConicCurveCmd", json!({"start": [210, 0], "end": [200, 10], "apex": [210, 10], "rho": w / (1.0 + w)}));
let kid = ids(&k["curves"])[0].clone();
// A line tangent to the conic at its end: the tangent arc tool continues it.
let r = run(&mut s, "ArcTangent", json!({"start": format!("{kid}.end"), "end": [190, 0]}));
let aid = ids(&r["curves"])[0].clone();
let sk = sketch(&s);
// The conic is a quarter circle about (200, 0) leaving (200, 10) heading -x: the tangent
// arc from there to (190, 0) continues the same circle.
let ai = sk.curve_index(&aid).unwrap();
assert!(sk.center(ai).unwrap().dist(Vec2::new(200.0, 0.0)) < 1e-6, "{:?}", sk.center(ai));
assert!(s.execute("DrawSpline", &json!({"points": [[0, 0]]})).is_err());
assert!(s.execute("ConicCurveCmd", &json!({"start": [0, 0], "end": [1, 0], "apex": [0, 1], "rho": 1.5})).is_err());
// Mirror and pattern copy free-form curves too.
let m = ids(&run(&mut s, "DrawPolyline", json!({"points": [[-50, -50], [-50, 50]]}))["curves"])[0].clone();
let r = run(&mut s, "MirrorSketchCommand", json!({"entities": [eid, sid], "line": m}));
assert_eq!(ids(&r["curves"]).len(), 2);
}