Sketch constraints and dimensions: Curvature (G2, ConstraintSmooth) and Polygon constraints; redundant constraints and dimensions are refused as over-constraining (Fusion behaviour), with driven (reference) dimensions that measure instead; arc length and linear diameter (about a centerline) dimensions; sketch.glyphs (constraint glyph anchors, conflicts) and sketch.snap (coincident/midpoint/on-curve/horizontal/vertical inference); solver damping floor keeps weakly seen variables from flying off

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 23:10:03 +00:00
1 parent a1accbf46e
commit 54ee20bef4
10 files changed
+668 -50

No files matched your search

+2
View File
@@ -10,6 +10,7 @@ pub use features::auto_operation;
mod file;
mod inspect;
mod sketch;
mod sketch_constraints;
mod sketch_create;
mod sketch_modify;
mod sketch_project;
@@ -110,6 +111,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
let mut v: Vec<&'static CommandSpec> = Vec::new();
v.extend(sketch::COMMANDS.iter());
v.extend(sketch_create::COMMANDS.iter());
v.extend(sketch_constraints::COMMANDS.iter());
v.extend(sketch_modify::COMMANDS.iter());
v.extend(sketch_project::COMMANDS.iter());
v.extend(features::COMMANDS.iter());
+71 -5
View File
@@ -786,13 +786,33 @@ fn draw_point(s: &mut Session, p: &Value) -> Result<Value> {
// Constraints
fn constrain(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&Sketch) -> Result<ConstraintKind>) -> Result<Value> {
let mut before = None;
let (id, info) = edit(s, p, cmd, true, |sk, _| {
let k = f(sk)?;
before = Some(dof_now(sk));
add_c(sk, k)
})?;
reject_redundant(before, &info, cmd)?;
Ok(json!({"constraint": id, "sketch": info}))
}
/// Degrees of freedom of a sketch as it is.
pub(super) fn dof_now(sk: &Sketch) -> usize {
solve(&mut sk.clone()).dof
}
/// A constraint that removes no degree of freedom over-constrains the sketch (Fusion refuses
/// it); the caller's edit is rolled back by returning the error.
pub(super) fn reject_redundant(before: Option<usize>, info: &Value, cmd: &str) -> Result<()> {
let after = info.get("dof").and_then(Value::as_u64).map(|d| d as usize);
if let (Some(b), Some(a)) = (before, after)
&& a >= b
{
return Err(bad(cmd, "that would over-constrain the sketch (it is already determined by other constraints)"));
}
Ok(())
}
fn c_horizontal_vertical(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ConstraintHorizontalVertical";
let mode = str_(p, "mode").map(str::to_ascii_lowercase);
@@ -962,7 +982,10 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
};
let value = expr(p, "value");
let text_at = p.get("text_at").and_then(vec2);
let driven = bool_(p, "driven").unwrap_or(false);
let mut before = None;
let ((param, kind_name, current), info) = edit(s, p, cmd, true, |sk, doc| {
before = Some(dof_now(sk));
let ent = |r: &str| -> Result<(Option<usize>, Option<usize>)> {
if let Some(c) = sk.curve_index(r) {
return Ok((None, Some(c)));
@@ -992,8 +1015,12 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
_ => (ConstraintKind::Length { l: c, value: a.dist(b) }, a.dist(b)),
}
}
((None, Some(c)), None) if ty == "arc_length" || ty == "arclength" => {
let len = arc_len(sk, c).ok_or_else(|| bad(cmd, "arc length needs an arc"))?;
(ConstraintKind::ArcLength { c, value: len }, len)
}
((None, Some(c)), None) => {
let r = sk.radius(c).unwrap_or(1.0);
let r = sk.radius(c).ok_or_else(|| bad(cmd, "cannot dimension that curve"))?;
let diameter = ty == "diameter" || (ty != "radius" && is_circle(c));
if diameter { (ConstraintKind::Diameter { c, value: 2.0 * r }, 2.0 * r) } else { (ConstraintKind::Radius { c, value: r }, r) }
}
@@ -1009,17 +1036,27 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
let (a, b) = line_pts(sk, l).unwrap_or_default();
let pq = sk.point(q).unwrap_or_default();
let d = (b - a).normalized().map(|d| d.cross(pq - a).abs()).unwrap_or(0.0);
(ConstraintKind::PointLineDistance { p: q, l, value: d }, d)
if diameter_about(sk, l, &ty) {
(ConstraintKind::LinearDiameter { p: q, l, value: 2.0 * d }, 2.0 * d)
} else {
(ConstraintKind::PointLineDistance { p: q, l, value: d }, d)
}
}
((None, Some(a)), Some((None, Some(b)))) if is_line(a) && is_line(b) => {
let (a0, a1) = line_pts(sk, a).unwrap_or_default();
let (b0, b1) = line_pts(sk, b).unwrap_or_default();
let (da, db) = (a1 - a0, b1 - b0);
if da.cross(db).abs() < 1e-9 * da.len() * db.len() && ty != "angle" {
// Parallel lines: distance between them.
// Parallel lines: distance between them (a diameter about a centerline).
let (a, b, b0) = if sk.curves.get(a).is_some_and(|c| c.centerline) { (a, b, b0) } else { (b, a, a0) };
let (bq, _) = line_ends(sk, b);
let d = da.normalized().map(|d| d.cross(b0 - a0).abs()).unwrap_or(0.0);
(ConstraintKind::PointLineDistance { p: bq, l: a, value: d }, d)
let (a0, a1) = line_pts(sk, a).unwrap_or_default();
let d = (a1 - a0).normalized().map(|d| d.cross(b0 - a0).abs()).unwrap_or(0.0);
if diameter_about(sk, a, &ty) {
(ConstraintKind::LinearDiameter { p: bq, l: a, value: 2.0 * d }, 2.0 * d)
} else {
(ConstraintKind::PointLineDistance { p: bq, l: a, value: d }, d)
}
} else {
match text_at {
Some(t) => angle_by_sector(a, b, (a0, a1), (b0, b1), t).ok_or_else(|| bad(cmd, "cannot place that angle"))?,
@@ -1038,6 +1075,16 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
_ => return Err(bad(cmd, "cannot dimension that combination of entities")),
};
let is_angle = k.is_angle();
if driven {
// A reference dimension: measures, no parameter.
let name = k.name();
let id = sk.add_constraint(k, None)?;
if let Some(c) = sk.constraints.iter_mut().find(|c| c.id == id) {
c.driven = true;
}
before = None;
return Ok((String::new(), name, cur));
}
let (unit, default_expr) =
if is_angle { ("deg", format!("{} deg", round6(cur.to_degrees()))) } else { ("mm", format!("{} mm", round6(cur))) };
let e = value.clone().unwrap_or(default_expr);
@@ -1052,10 +1099,29 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
sk.add_constraint(k, Some(pname.clone()))?;
Ok((pname, name, cur))
})?;
if driven {
return Ok(json!({"param": Value::Null, "driven": true, "type": kind_name, "measured": current, "sketch": info}));
}
if reject_redundant(before, &info, cmd).is_err() {
return Err(bad(cmd, "that dimension would over-constrain the sketch; add it as a driven (reference) dimension with driven: true"));
}
let v = s.doc.param(&param).map(|p| p.expr.clone()).unwrap_or_default();
Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current, "sketch": info}))
}
/// Dimension a point (or a parallel line) to line `l` as a diameter: when `l` is a centerline
/// (unless a plain distance was asked for) or when a diameter was asked for.
fn diameter_about(sk: &Sketch, l: usize, ty: &str) -> bool {
ty == "diameter" || (ty == "auto" && sk.curves.get(l).is_some_and(|c| c.centerline))
}
fn arc_len(sk: &Sketch, c: usize) -> Option<f64> {
match sk.segs(c).first()? {
solvecraft_geom::Seg2::Arc { radius, sweep, .. } if matches!(sk.curves.get(c)?.kind, CurveKind::Arc { .. }) => Some(radius * sweep),
_ => None,
}
}
/// The angle dimension between two lines whose sector contains `t` (the text position): returns
/// the constraint and the current value of that sector angle.
fn angle_by_sector(a: usize, b: usize, (a0, a1): (Vec2, Vec2), (b0, b1): (Vec2, Vec2), t: Vec2) -> Option<(ConstraintKind, f64)> {
+209
View File
@@ -0,0 +1,209 @@
//! More sketch constraints (curvature, polygon), constraint glyph anchors and snapping
//! inference for interactive tools.
use serde_json::{Value, json};
use solvecraft_geom::Vec2;
use solvecraft_sketch::{ConstraintKind, CurveKind, Sketch};
use super::sketch::{add_c, dof_now, edit, reject_redundant};
use super::{CommandSpec, in_sketch};
use crate::params::{bad, num, req_vec2, string_list, vec2};
use crate::{EngineError, Result, Session};
pub static COMMANDS: &[CommandSpec] = &[
CommandSpec::new("ConstraintSmooth", "Curvature", c_smooth)
.at("SKETCH", "CONSTRAINTS")
.icon("c_smooth")
.enabled(in_sketch)
.params("a, b: two curves sharing an end point (curvature continuous, G2)"),
CommandSpec::new("SketchPolygonConstraintCmd", "Polygon", c_polygon)
.at("SKETCH", "CONSTRAINTS")
.icon("c_polygon")
.enabled(in_sketch)
.params("lines: [line ids] of a closed chain: equal sides with every corner on one circle (a regular polygon)"),
CommandSpec::new("sketch.glyphs", "Constraint Glyphs", glyphs)
.enabled(in_sketch)
.noundo()
.params("sketch?: id|name: every constraint with where to draw its glyph (sketch coordinates)"),
CommandSpec::new("sketch.snap", "Snap Inference", snap)
.enabled(in_sketch)
.noundo()
.params("at: [x,y], from?: [x,y] (previous click), radius?: mm (snap distance, default 1): what a click there would snap to and infer"),
];
fn c_smooth(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "ConstraintSmooth";
let mut before = None;
let (id, info) = edit(s, p, cmd, true, |sk, _| {
let a = curve(sk, p.get("a"), cmd)?;
let b = curve(sk, p.get("b"), cmd)?;
before = Some(dof_now(sk));
add_c(sk, ConstraintKind::Smooth { a, b })
})?;
reject_redundant(before, &info, cmd)?;
Ok(json!({"constraint": id, "sketch": info}))
}
fn curve(sk: &Sketch, v: Option<&Value>, cmd: &str) -> Result<usize> {
let id = v.and_then(Value::as_str).ok_or_else(|| bad(cmd, "expected a curve id"))?;
sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))
}
fn c_polygon(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "SketchPolygonConstraintCmd";
let ids = string_list(p, "lines");
if ids.len() < 3 || ids.len() > 200 {
return Err(bad(cmd, "`lines` must list 3 to 200 lines of a closed chain"));
}
let (out, info) = edit(s, p, cmd, true, |sk, _| {
let ls: Vec<usize> = ids.iter().map(|i| sk.curve_index(i).ok_or_else(|| bad(cmd, format!("unknown curve `{i}`")))).collect::<Result<_>>()?;
let mut corners: Vec<usize> = Vec::new();
for l in &ls {
match sk.curves.get(*l).map(|c| &c.kind) {
Some(CurveKind::Line { a, b }) => {
for q in [*a, *b] {
if !corners.contains(&q) {
corners.push(q);
}
}
}
_ => return Err(bad(cmd, "the polygon must be made of lines")),
}
}
if corners.len() != ls.len() {
return Err(bad(cmd, "the lines must form one closed chain"));
}
let pts: Vec<Vec2> = corners.iter().filter_map(|q| sk.point(*q)).collect();
let c = pts.iter().fold(Vec2::ZERO, |a, q| a + *q) / pts.len() as f64;
let r = pts.iter().map(|q| q.dist(c)).sum::<f64>() / pts.len() as f64;
let circ = sk.add_circle(c, r.max(1e-6), None, None)?;
if let Some(cu) = sk.curves.get_mut(circ) {
cu.construction = true;
}
let mut cons = Vec::new();
for w in ls.windows(2) {
cons.push(add_c(sk, ConstraintKind::Equal { a: w[0], b: w[1] })?);
}
for q in corners {
cons.push(add_c(sk, ConstraintKind::PointOnCurve { p: q, c: circ })?);
}
Ok(cons)
})?;
Ok(json!({"constraints": out, "sketch": info}))
}
/// Where a constraint's glyph goes (sketch coordinates): beside the middle of its first curve,
/// or at its point.
fn anchor(sk: &Sketch, k: &ConstraintKind) -> Option<Vec2> {
use ConstraintKind::*;
let mid = |c: usize| -> Option<Vec2> {
let poly = sk.polyline(c);
poly.get(poly.len() / 2).copied()
};
let pt = |p: usize| sk.point(p);
match *k {
Coincident { p, .. } | Fix { p } | Midpoint { p, .. } => pt(p),
PointOnCurve { p, .. } => pt(p),
HorizontalPoints { p, q } | VerticalPoints { p, q } | Symmetric { p, q, .. } => Some((pt(p)? + pt(q)?) * 0.5),
Horizontal { l } | Vertical { l } => mid(l),
Parallel { a, .. } | Perpendicular { a, .. } | Collinear { a, .. } | Equal { a, .. } | Concentric { a, .. } | Angle { a, .. } => mid(a),
Tangent { a, b } | Smooth { a, b } => {
// At the shared end when there is one.
let ends = |c: usize| sk.curves.get(c).and_then(|c| c.kind.ends());
match (ends(a), ends(b)) {
(Some((a0, a1)), Some((b0, b1))) => [a0, a1].into_iter().find(|x| *x == b0 || *x == b1).and_then(pt).or_else(|| mid(a)),
_ => mid(a),
}
}
Distance { p, q, .. } | DistanceX { p, q, .. } | DistanceY { p, q, .. } => Some((pt(p)? + pt(q)?) * 0.5),
PointLineDistance { p, .. } | LinearDiameter { p, .. } => pt(p),
Length { l, .. } => mid(l),
Radius { c, .. } | Diameter { c, .. } | ArcLength { c, .. } => mid(c),
}
}
fn glyphs(s: &mut Session, p: &Value) -> Result<Value> {
let id = match p.get("sketch") {
Some(Value::Number(n)) => n.as_u64(),
Some(Value::String(x)) => s.doc.find_feature(x).map(|f| f.id),
_ => s.active_sketch,
}
.ok_or_else(|| bad("sketch.glyphs", "no such sketch"))?;
let st = s.model.state();
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("that sketch is not evaluated".into()))?;
let failing = &ss.report.failing;
let list: Vec<Value> = ss
.sketch
.constraints
.iter()
.map(|c| {
json!({
"id": c.id,
"name": c.kind.name(),
"dimension": c.kind.is_dimension(),
"driven": c.driven,
"value": c.kind.value(),
"param": c.param,
"at": anchor(&ss.sketch, &c.kind),
"failing": failing.contains(&c.id),
})
})
.collect();
Ok(json!({"sketch": id, "glyphs": list, "dof": ss.report.dof, "fully_constrained": ss.report.fully_constrained(), "conflicts": failing}))
}
/// Snap inference for a click at `at`: the nearest sketch point (coincident), the midpoint of a
/// line, a point on a curve, and horizontal/vertical alignment with `from` or with other
/// points. Results are ordered by priority; the first one is what a click would use.
fn snap(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.snap";
let at = req_vec2(cmd, p, "at")?;
let from = p.get("from").and_then(vec2);
let r = num(p, "radius").filter(|r| *r > 0.0).unwrap_or(1.0);
let id = s.active_sketch.ok_or_else(|| bad(cmd, "no sketch is being edited"))?;
let st = s.model.state();
let ss = st.sketch(id).ok_or_else(|| EngineError::Other("the sketch is not evaluated".into()))?;
let sk = &ss.sketch;
let mut out: Vec<(f64, Value)> = Vec::new();
// Points (end points, centres, the origin).
for q in &sk.points {
let d = q.pos.dist(at);
if d <= r {
out.push((d * 0.5, json!({"type": "coincident", "point": q.id, "at": q.pos})));
}
}
for (i, c) in sk.curves.iter().enumerate() {
if let CurveKind::Line { a, b } = c.kind
&& let (Some(pa), Some(pb)) = (sk.point(a), sk.point(b))
{
let m = (pa + pb) * 0.5;
let d = m.dist(at);
if d <= r {
out.push((d * 0.6, json!({"type": "midpoint", "curve": c.id, "at": m})));
}
}
if let Some(sh) = sk.shape(i) {
let q = sh.project(at);
if sh.dist(at) <= r && q.dist(at) <= r {
out.push((sh.dist(at) + r * 0.5, json!({"type": "on_curve", "curve": c.id, "at": q})));
}
}
}
// Alignment with the previous click.
if let Some(f) = from {
let d = at - f;
if d.y.abs() <= r && d.x.abs() > r {
out.push((d.y.abs() + r, json!({"type": "horizontal", "at": [at.x, f.y]})));
}
if d.x.abs() <= r && d.y.abs() > r {
out.push((d.x.abs() + r, json!({"type": "vertical", "at": [f.x, at.y]})));
}
}
out.sort_by(|a, b| a.0.total_cmp(&b.0));
let list: Vec<Value> = out.into_iter().take(8).map(|x| x.1).collect();
Ok(json!({"snaps": list}))
}
#[cfg(test)]
#[path = "sketch_constraints_tests.rs"]
mod tests;
@@ -0,0 +1,113 @@
use serde_json::{Value, json};
use solvecraft_geom::Vec2;
use crate::Session;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
match s.execute(id, &p) {
Ok(v) => v,
Err(e) => panic!("{id} {p}: {e}"),
}
}
fn ids(v: &Value) -> Vec<String> {
v.as_array().map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect()).unwrap_or_default()
}
fn new_sketch() -> Session {
let mut s = Session::default();
run(&mut s, "SketchCreate", json!({"plane": "XY"}));
s
}
fn sketch(s: &Session) -> solvecraft_sketch::Sketch {
s.model.state().sketch(s.active_sketch.unwrap()).unwrap().sketch.clone()
}
#[test]
fn redundant_constraints_and_dimensions_are_refused_driven_ones_measure() {
let mut s = new_sketch();
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[0, 0], [10, 0]], "infer": true}))["curves"])[0].clone();
// Already horizontal (inferred).
let e = s.execute("ConstraintHorizontalVertical", &json!({"line": l})).unwrap_err().to_string();
assert!(e.contains("over-constrain"), "{e}");
run(&mut s, "ConstraintCoincident", json!({"a": format!("{l}.start"), "b": "origin"}));
run(&mut s, "SketchDimension", json!({"entities": [l], "value": 10}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert_eq!(si["dof"], 0, "{si}");
// A second length on it over-constrains: refused, unless driven.
assert!(s.execute("SketchDimension", &json!({"entities": [format!("{l}.start"), format!("{l}.end")]})).is_err());
let d = run(&mut s, "SketchDimension", json!({"entities": [format!("{l}.start"), format!("{l}.end")], "driven": true}));
assert_eq!(d["driven"], true);
// The driven dimension follows the driving one.
let pname = si["constraints"].as_array().unwrap().iter().find_map(|c| c["param"].as_str().map(str::to_string)).unwrap();
run(&mut s, "ChangeParameterCommand", json!({"name": pname, "expression": "25 mm"}));
let g = run(&mut s, "sketch.glyphs", json!({}));
let driven = g["glyphs"].as_array().unwrap().iter().find(|x| x["driven"] == true).unwrap().clone();
assert!((driven["value"].as_f64().unwrap() - 25.0).abs() < 1e-7, "{driven}");
assert_eq!(g["fully_constrained"], true);
}
#[test]
fn curvature_polygon_arc_length_linear_diameter() {
let mut s = new_sketch();
// A horizontal line ending where a control-point spline starts: G2 keeps the spline's
// start straight (zero curvature) and tangent.
let l = ids(&run(&mut s, "DrawPolyline", json!({"points": [[-10, 0], [0, 0]], "infer": true}))["curves"])[0].clone();
let sp = ids(&run(&mut s, "DrawCVMSpline3D", json!({"points": [format!("{l}.end"), [5, 2], [10, 6], [15, 15]]}))["curves"])[0].clone();
run(&mut s, "ConstraintSmooth", json!({"a": l, "b": sp}));
let sk = sketch(&s);
let pts: Vec<Vec2> = sk.curves[sk.curve_index(&sp).unwrap()].kind.point_ids().iter().map(|i| sk.point(*i).unwrap()).collect();
// Tangent: first leg along +x; zero curvature: the second leg too (collinear first three).
assert!((pts[1] - pts[0]).y.abs() < 1e-5, "{pts:?}");
assert!((pts[2] - pts[1]).cross(pts[1] - pts[0]).abs() < 1e-3, "{pts:?}");
// Polygon constraint on a rough quadrilateral: becomes a square-ish regular polygon.
let q = run(&mut s, "DrawPolyline", json!({"points": [[100, 0], [111, 1], [110, 9], [99, 10]], "closed": true}));
let ql = ids(&q["curves"]);
run(&mut s, "SketchPolygonConstraintCmd", json!({"lines": ql}));
let sk = sketch(&s);
let lens: Vec<f64> = ql.iter().map(|i| sk.polyline(sk.curve_index(i).unwrap())).map(|p| p[0].dist(p[p.len() - 1])).collect();
assert!(lens.iter().all(|x| (x - lens[0]).abs() < 1e-6), "{lens:?}");
// Arc length.
let a = ids(&run(&mut s, "ArcCenterTwoPoint", json!({"center": [0, 50], "start": [10, 50], "end": [0, 60]}))["curves"])[0].clone();
run(&mut s, "SketchDimension", json!({"entities": [a], "type": "arc_length", "value": 20}));
let sk = sketch(&s);
let ai = sk.curve_index(&a).unwrap();
let len = match sk.segs(ai)[0] {
solvecraft_geom::Seg2::Arc { radius, sweep, .. } => radius * sweep,
_ => 0.0,
};
assert!((len - 20.0).abs() < 1e-6, "{len}");
// Linear diameter about a centerline.
let cl = ids(&run(&mut s, "DrawPolyline", json!({"points": [[200, 0], [200, 50]]}))["curves"])[0].clone();
run(&mut s, "sketch.centerline", json!({"curves": [cl]}));
run(&mut s, "DrawPoint", json!({"point": [207, 10], "id": "rim"}));
let d = run(&mut s, "SketchDimension", json!({"entities": ["rim", cl], "value": 30}));
assert_eq!(d["type"], "LinearDiameterDimension", "{d}");
let sk = sketch(&s);
let rim = sk.point(sk.point_index("rim").unwrap()).unwrap();
let line = sk.polyline(sk.curve_index(&cl).unwrap());
let (a, b) = (line[0], line[line.len() - 1]);
let dist = (b - a).normalized().unwrap().cross(rim - a).abs();
assert!((dist - 15.0).abs() < 1e-6, "{rim:?} {dist}");
}
#[test]
fn glyphs_and_snaps() {
let mut s = new_sketch();
let r = run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [20, 10]}));
let l = ids(&r["curves"]);
let g = run(&mut s, "sketch.glyphs", json!({}));
assert_eq!(g["glyphs"].as_array().unwrap().len(), 4);
assert!(g["glyphs"][0]["at"].is_array());
let sn = run(&mut s, "sketch.snap", json!({"at": [20.3, 0.2], "radius": 1}));
assert_eq!(sn["snaps"][0]["type"], "coincident", "{sn}");
let sn = run(&mut s, "sketch.snap", json!({"at": [10.2, 0.3], "radius": 1}));
assert_eq!(sn["snaps"][0]["type"], "midpoint", "{sn}");
assert_eq!(sn["snaps"][0]["curve"], json!(l[0]));
let sn = run(&mut s, "sketch.snap", json!({"at": [40, 10.4], "from": [30, 10], "radius": 1}));
assert!(sn["snaps"].as_array().unwrap().iter().any(|x| x["type"] == "horizontal"), "{sn}");
}