Sketch: a fillet stays the arc of its corner when the corner mirrors or a line is dragged through it (#5677)

A line tangent to an arc at the arc's end had no sense: the tangency held
with the line on either side of its touch point, so pinning a line's far
end across the corner left the line cutting back through its fillet, and
when the solver found the mirrored corner the arc kept its stored
direction and became its three-quarter complement, inside out.

The solver now keeps each fillet an arc of its corner. A line tangent at an
arc's end keeps its sense there (it leaves the arc as it did) and the arc
its side of its chord, compared with the positions before an interactive
move or the stored ones. A corner that mirrored gets its arc on the
mirrored side: its ends swapped where the solve ran the arc round the
other way with the lines following, or the arc reflected across the line
that stayed and solved again where a line cut back through. A solve that
would loop the arc the other way round, or fold a line onto it, is refused
as a fold is. A drag's cursor-following steps keep the fillets too, so a
line dragged through its own fillet stops at the arc.
This commit is contained in:
Kai Evans
2026-10-26 10:42:41 +00:00
committed by GitHub
parent 9d0b509961
commit 41a938e731
5 changed files with 399 additions and 8 deletions
+1 -1
View File
@@ -294,7 +294,7 @@ pub(super) fn drag_entity(
let valid = |before: &Sketch, after: &Sketch| {
let mut geometry = Geometry::default();
geometry.take(before);
!geometry.sweep_wrapped(after)
!geometry.sweep_wrapped(after) && solidcraft_sketch::fillets_kept(before, after)
};
let followed = match (e, line_grab) {
(Ent::Point(q), _) => solidcraft_sketch::move_point_towards(sk, q, to, max_step, frame.as_mut(), valid),
@@ -2492,3 +2492,50 @@ fn freeform_chain_offsets_take_the_straight_pieces_of_the_chain_along() {
assert!(!curves.is_empty(), "{out}");
certified(&s, &curves);
}
/// The arc's counter-clockwise sweep from its stored start to its end, degrees, from `sketch.inspect`.
fn arc_sweep_deg(si: &Value, id: &str) -> f64 {
let c = si["curves"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap();
let at = |k: &str| Vec2::new(c[k][0].as_f64().unwrap(), c[k][1].as_f64().unwrap());
let (centre, a, b) = (at("center_at"), at("start_at"), at("end_at"));
((b - centre).angle() - (a - centre).angle()).rem_euclid(std::f64::consts::TAU).to_degrees()
}
#[test]
fn a_fillet_stays_a_quarter_arc_when_its_corner_is_pinned_or_dragged_to_the_other_side() {
let mut s = new_sketch();
run(&mut s, "sketch.line", json!({"points": [[10, 10], [40, 10], [40, 30]]}));
let f = run(&mut s, "sketch.fillet", json!({"point": "l1.end", "radius": 5}));
let arc = ids(&f["curves"])[0].clone();
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l1"}));
run(&mut s, "sketch.constraint.horizontal_vertical", json!({"line": "l2"}));
let curve = |si: &Value, id: &str| si["curves"].as_array().unwrap().iter().find(|c| c["id"] == id).unwrap().clone();
// Pin the vertical line's far end below the horizontal line: the corner mirrors, and the
// fillet is the quarter arc inside the mirrored corner (its lines leave it, none cuts
// through it).
run(&mut s, "sketch.point", json!({"point": [40, -20], "id": "q"}));
run(&mut s, "sketch.constraint.fix", json!({"entity": "q"}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "l2.end", "b": "q"}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert!((arc_sweep_deg(&si, &arc) - 90.0).abs() < 1e-6, "{}", arc_sweep_deg(&si, &arc));
assert_eq!(curve(&si, &arc)["center_at"], json!([35.0, 5.0]), "{}", curve(&si, &arc));
assert_eq!(curve(&si, "l2")["start_at"], json!([40.0, 5.0]), "{}", curve(&si, "l2"));
// Dragging the horizontal line's far end through the corner would push the line through its
// fillet: the drag stops at the arc.
let d = run(&mut s, "sketch.drag", json!({"entity": "l1.start", "from": [10, 10], "to": [70, 10]}));
assert!(d["reached"].as_f64().unwrap() < 0.5, "{d}");
let si = run(&mut s, "sketch.inspect", json!({}));
let l1 = curve(&si, "l1");
assert!(l1["start_at"][0].as_f64().unwrap() <= 35.0 + 1e-6, "{l1}");
assert!((arc_sweep_deg(&si, &arc) - 90.0).abs() < 1e-6, "{}", arc_sweep_deg(&si, &arc));
assert_eq!(curve(&si, &arc)["center_at"], json!([35.0, 5.0]), "{}", curve(&si, &arc));
// Pinned on the far side instead, the corner mirrors again and the fillet follows.
run(&mut s, "sketch.point", json!({"point": [70, 10], "id": "r"}));
run(&mut s, "sketch.constraint.fix", json!({"entity": "r"}));
run(&mut s, "sketch.constraint.coincident", json!({"a": "l1.start", "b": "r"}));
let si = run(&mut s, "sketch.inspect", json!({}));
assert!((arc_sweep_deg(&si, &arc) - 90.0).abs() < 1e-6, "{}", arc_sweep_deg(&si, &arc));
assert_eq!(curve(&si, &arc)["center_at"], json!([45.0, 5.0]), "{}", curve(&si, &arc));
assert_eq!(curve(&si, "l1")["end_at"], json!([45.0, 10.0]), "{}", curve(&si, "l1"));
assert_eq!(curve(&si, "l2")["start_at"], json!([40.0, 5.0]), "{}", curve(&si, "l2"));
}
+3 -3
View File
@@ -66,9 +66,9 @@ pub use profiles::{
pub use shape::{Shape, intersections, intersections_within, nearest_crossing, nearest_curve_intersection};
pub use solver::solve_after_edit_with_analysis;
pub use solver::{
Analysis, CONSISTENT, DrivingValues, Hold, MAX_COMPONENT_VARS, SolveReport, SolveStatus, constraint_residual, determined_by, flips_from_stored,
measure_dimension, move_line_towards, move_point_towards, solve, solve_after_edit, solve_after_link_move, solve_after_value_edit, solve_holding,
solve_with,
Analysis, CONSISTENT, DrivingValues, Hold, MAX_COMPONENT_VARS, SolveReport, SolveStatus, constraint_residual, determined_by, fillets_kept,
flips_from_stored, measure_dimension, move_line_towards, move_point_towards, solve, solve_after_edit, solve_after_link_move,
solve_after_value_edit, solve_holding, solve_with,
};
pub use text::{MAX_TEXT_CHARS, TextAlign, TextMetrics, TextStyle, text_geometry, text_geometry_in, text_geometry_styled};
pub use thin::{MAX_THIN_SEGMENTS, thin_wall_regions};
+250 -4
View File
@@ -1857,7 +1857,7 @@ fn solve_from(
old_values: Option<&[Option<f64>]>,
budget: &mut Budget,
) -> SolveReport {
solve_inner(sk, hold, reference, analysis, old_values, budget)
solve_sensed(sk, hold, reference, analysis, old_values, budget)
}
/// In this crate's tests every solve is repeated without the constructive seed: the seed only
@@ -1877,11 +1877,11 @@ fn solve_from(
) -> SolveReport {
use crate::construct::switch;
if !(switch::seed_on() && switch::differential_on()) {
return solve_inner(sk, hold, reference, analysis, old_values, budget);
return solve_sensed(sk, hold, reference, analysis, old_values, budget);
}
let mut plain = sk.clone();
let without = switch::without_seed(|| solve_inner(&mut plain, hold, reference, analysis, old_values, &mut Budget::full()));
let with = solve_inner(sk, hold, reference, analysis, old_values, budget);
let without = switch::without_seed(|| solve_sensed(&mut plain, hold, reference, analysis, old_values, &mut Budget::full()));
let with = solve_sensed(sk, hold, reference, analysis, old_values, budget);
assert_same_outcome(&with, &without);
with
}
@@ -2267,6 +2267,252 @@ fn continue_values(
(spent, t >= SUBSTEPS)
}
/// A line tangent to an arc at the arc's end `p` continues the arc's travel there, as a
/// fillet's line leaves its arc: `Some(1.0)`; turned back onto the arc, `Some(-1.0)`; `None`
/// when too short to tell. The travel runs counter-clockwise from the arc's `a` to its `b`.
fn tangent_sense(sk: &Sketch, pos: &dyn Fn(usize) -> Vec2, arc: usize, line: usize, p: usize) -> Option<f64> {
let CurveKind::Arc { c, a, b } = sk.curves.get(arc)?.kind else { return None };
let CurveKind::Line { a: la, b: lb } = sk.curves.get(line)?.kind else { return None };
let far = if la == p {
lb
} else if lb == p {
la
} else {
return None;
};
let outward = if p == b {
1.0
} else if p == a {
-1.0
} else {
return None;
};
let along = (pos(p) - pos(c)).perp();
let leaving = pos(far) - pos(p);
let d = along.dot(leaving) * outward;
(d.abs() > 1e-9 * along.len() * leaving.len()).then(|| d.signum())
}
/// An arc with lines tangent at its ends, as a solve started or left it: its sweep (counter-
/// clockwise from `a` to `b`) and each line's sense.
struct ArcSense {
arc: usize,
sweep: f64,
/// (line, touch point, tangent constraint index, sense)
lines: Vec<(usize, usize, usize, Option<f64>)>,
}
/// The arcs a solve may turn over (no arc sweep dimension of their own, not fixed, not
/// linked) with the lines tangent at their ends, read at `pos`.
fn arc_senses(sk: &Sketch, pos: &dyn Fn(usize) -> Vec2) -> Vec<ArcSense> {
let swept: std::collections::BTreeSet<usize> = sk
.constraints
.iter()
.filter(|c| !c.driven)
.filter_map(|c| match c.kind {
ConstraintKind::ArcSweep { c, .. } => Some(c),
_ => None,
})
.collect();
let arc =
|i: usize| sk.curves.get(i).is_some_and(|c| matches!(c.kind, CurveKind::Arc { .. }) && !c.fixed && c.link.is_none() && !swept.contains(&i));
let line = |i: usize| sk.curves.get(i).is_some_and(|c| matches!(c.kind, CurveKind::Line { .. }));
let on_curve = points_on_curves(sk);
let mut out: Vec<ArcSense> = Vec::new();
for (ci, c) in sk.constraints.iter().enumerate().filter(|(_, c)| !c.driven) {
let ConstraintKind::Tangent { a, b } = c.kind else { continue };
let Some(p) = touch_point(sk, &c.kind, &on_curve) else { continue };
let (arc, line) = if arc(a) && line(b) {
(a, b)
} else if arc(b) && line(a) {
(b, a)
} else {
continue;
};
let sense = tangent_sense(sk, pos, arc, line, p);
let entry = match out.iter().position(|e| e.arc == arc) {
Some(k) => k,
None => {
let CurveKind::Arc { c, a, b } = sk.curves[arc].kind else { continue };
let sweep = ((pos(b) - pos(c)).angle() - (pos(a) - pos(c)).angle()).rem_euclid(std::f64::consts::TAU);
out.push(ArcSense { arc, sweep, lines: Vec::new() });
out.len() - 1
}
};
if let Some(e) = out.get_mut(entry) {
e.lines.push((line, p, ci, sense));
}
}
out
}
/// What a solve did to a filleted arc, against how it started.
enum Turn {
/// Still the same arc between its lines.
Kept,
/// It ran round the other way with its lines following: the corner it rounds mirrored. Its
/// ends swapped, it is that corner's short arc again.
Swap,
/// One of its two lines cut back through it: the corner mirrored, the arc did not. The
/// arc reflected across the line that stayed (`across`) with that line's touch point
/// (`p`) is the start to solve again from.
Mirror { p: usize, across: usize },
/// It looped the other way round, or a line folded back onto it: no arc of a corner.
Over(Vec<usize>),
}
fn turn(before: &ArcSense, after: &ArcSense) -> Turn {
let jump = (after.sweep - before.sweep).abs() > std::f64::consts::PI;
// A line whose sense was clear and no longer is collapsed onto the arc: turned too.
let lines: Vec<(&(usize, usize, usize, Option<f64>), bool)> = before
.lines
.iter()
.filter_map(|l @ (line, p, _, was)| {
let was = (*was)?;
let is = after.lines.iter().find(|(m, q, ..)| (m, q) == (line, p)).map(|(.., is)| *is)?;
Some((l, is != Some(was)))
})
.collect();
let (all, any) = (lines.iter().all(|(_, f)| *f), lines.iter().any(|(_, f)| *f));
match (jump, any, all) {
(false, false, _) => Turn::Kept,
(true, _, true) => Turn::Swap,
(false, true, false) if lines.len() == 2 => {
let Some(turned) = lines.iter().find(|(_, f)| *f).map(|(l, _)| l) else { return Turn::Kept };
let Some(stayed) = lines.iter().find(|(_, f)| !*f).map(|(l, _)| l) else { return Turn::Kept };
Turn::Mirror { p: turned.1, across: stayed.0 }
}
_ => Turn::Over(lines.iter().map(|(l, _)| l.2).collect()),
}
}
/// The arc's ends swapped: the same circle run the other way round from the same points, so the
/// stored counter-clockwise arc is the one on the other side of its chord. `reversed` keeps the
/// as-drawn start and end.
fn swap_arc_ends(sk: &mut Sketch, arc: usize) {
if let Some(curve) = sk.curves.get_mut(arc)
&& let CurveKind::Arc { a, b, .. } = &mut curve.kind
{
std::mem::swap(a, b);
curve.reversed = !curve.reversed;
}
}
/// The arc's centre and the touch point `p` reflected across line `across`, and its ends
/// swapped: the corner's fillet on the mirrored side.
fn mirror_arc(sk: &mut Sketch, arc: usize, p: usize, across: usize) {
let Some(CurveKind::Arc { c, .. }) = sk.curves.get(arc).map(|c| c.kind.clone()) else { return };
let Some(CurveKind::Line { a: la, b: lb }) = sk.curves.get(across).map(|c| c.kind.clone()) else { return };
let (Some(pa), Some(pb)) = (sk.point(la), sk.point(lb)) else { return };
let Some(d) = (pb - pa).normalized() else { return };
let reflect = |q: Vec2| pa + d * (q - pa).dot(d) * 2.0 - (q - pa);
for i in [c, p] {
if let Some(q) = sk.point(i)
&& let Some(point) = sk.points.get_mut(i)
&& !point.fixed
{
point.pos = reflect(q);
}
}
swap_arc_ends(sk, arc);
}
/// Every fillet of `before` is still an arc of its corner in `after` (the same sketch moved: its
/// lines keep their sense at the arc and the arc its side of its chord): what a feasible move
/// of the cursor may keep. See [`solve_sensed`].
pub fn fillets_kept(before: &Sketch, after: &Sketch) -> bool {
let was = arc_senses(before, &|i| before.points.get(i).map(|p| p.pos).unwrap_or_default());
let now = arc_senses(after, &|i| after.points.get(i).map(|p| p.pos).unwrap_or_default());
was.iter().all(|b| now.iter().find(|a| a.arc == b.arc).is_none_or(|a| matches!(turn(b, a), Turn::Kept)))
}
/// The turns of every filleted arc from `before` to where `sk` is now, applying the swaps.
fn settle(sk: &mut Sketch, before: &[ArcSense]) -> Vec<(usize, Turn)> {
let after = arc_senses(sk, &|i| sk.points.get(i).map(|p| p.pos).unwrap_or_default());
let turns: Vec<(usize, Turn)> = before
.iter()
.filter_map(|b| after.iter().find(|a| a.arc == b.arc).map(|a| (b.arc, turn(b, a))))
.filter(|(_, t)| !matches!(t, Turn::Kept))
.collect();
for (arc, t) in &turns {
if matches!(t, Turn::Swap) {
swap_arc_ends(sk, *arc);
}
}
turns
}
/// [`solve_inner`], keeping each fillet an arc of its corner: a line tangent at an arc's end
/// keeps its sense there (it leaves the arc, as it did), and the arc stays on its side of its
/// chord. Positions are compared with `reference` (an interactive move's start) or the
/// stored ones. A corner that mirrored gets its arc on the mirrored side (its ends swapped, or
/// the arc reflected across the line that stayed and solved again). A solve that would loop
/// the arc round the other way, or fold a line back onto it, is refused as a fold is: the
/// geometry stays and the tangent constraints are reported as `folded`.
fn solve_sensed(
sk: &mut Sketch,
hold: &Hold,
reference: Option<&[Vec2]>,
analysis: Analysis,
old_values: Option<&[Option<f64>]>,
budget: &mut Budget,
) -> SolveReport {
let stored: Vec<Vec2> = sk.points.iter().map(|p| p.pos).collect();
let pos = |i: usize| reference.and_then(|r| r.get(i)).or_else(|| stored.get(i)).copied().unwrap_or_default();
let before = arc_senses(sk, &pos);
if before.is_empty() {
return solve_inner(sk, hold, reference, analysis, old_values, budget);
}
let start = sk.clone();
let mut report = solve_inner(sk, hold, reference, analysis, old_values, budget);
if !report.ok() {
return report;
}
let mut turns = settle(sk, &before);
let mirrors: Vec<(usize, usize, usize)> =
turns.iter().filter_map(|(arc, t)| if let Turn::Mirror { p, across } = t { Some((*arc, *p, *across)) } else { None }).collect();
if !mirrors.is_empty() {
let solved = sk.clone();
for (arc, p, across) in &mirrors {
mirror_arc(sk, *arc, *p, *across);
}
let again = solve_inner(sk, hold, reference, analysis, old_values, budget);
let again_turns = if again.ok() { settle(sk, &before) } else { Vec::new() };
if again.ok() && again_turns.is_empty() {
return again;
}
*sk = solved;
}
let over: Vec<usize> = turns
.iter_mut()
.flat_map(|(_, t)| match t {
Turn::Over(cs) => std::mem::take(cs),
Turn::Mirror { .. } => Vec::new(),
_ => Vec::new(),
})
.collect();
let mirrored_still: Vec<usize> = turns
.iter()
.filter(|(_, t)| matches!(t, Turn::Mirror { .. }))
.flat_map(|(arc, _)| before.iter().find(|b| b.arc == *arc).map(|b| b.lines.iter().map(|l| l.2).collect::<Vec<_>>()).unwrap_or_default())
.collect();
let folded: Vec<String> = over.into_iter().chain(mirrored_still).filter_map(|ci| sk.constraints.get(ci).map(|c| c.id.clone())).collect();
if folded.is_empty() {
return report;
}
*sk = start;
report.status = SolveStatus::Failed;
for id in folded {
if !report.failing.contains(&id) {
report.failing.push(id.clone());
}
if !report.folded.contains(&id) {
report.folded.push(id);
}
}
report
}
fn solve_inner(
sk: &mut Sketch,
hold: &Hold,
+98
View File
@@ -1181,3 +1181,101 @@ fn zero_component_dimensions_keep_independent_rank() {
}
}
}
/// A fillet as `sketch.fillet` makes it: `l1` from (10, 10) to (35, 10), the arc round
/// (35, 15) from (35, 10) to (40, 15), `l2` from (40, 15) to (40, 30); horizontal, vertical,
/// radius 5, tangent at both ends. Returns (l1, arc, l2, l1's far point, l2's far point).
fn filleted_corner() -> (Sketch, usize, usize, usize, usize, usize) {
let mut sk = Sketch::new();
let l1 = sk.add_line(v(10.0, 10.0), v(35.0, 10.0), None, None, None).unwrap();
let CurveKind::Line { a: far1, b: ta } = sk.curves[l1].kind else { panic!() };
let arc = sk.add_arc(v(35.0, 15.0), v(35.0, 10.0), v(40.0, 15.0), [None, Some(ta), None], None).unwrap();
let CurveKind::Arc { b: tb, .. } = sk.curves[arc].kind else { panic!() };
let l2 = sk.add_line(v(40.0, 15.0), v(40.0, 30.0), Some(tb), None, None).unwrap();
let CurveKind::Line { b: far2, .. } = sk.curves[l2].kind else { panic!() };
use ConstraintKind::*;
sk.add_constraint(Horizontal { l: l1 }, None).unwrap();
sk.add_constraint(Vertical { l: l2 }, None).unwrap();
sk.add_constraint(Radius { c: arc, value: 5.0 }, None).unwrap();
sk.add_constraint(Tangent { a: l1, b: arc }, None).unwrap();
sk.add_constraint(Tangent { a: l2, b: arc }, None).unwrap();
assert!(solve(&mut sk).ok());
(sk, l1, arc, l2, far1, far2)
}
/// The arc's counter-clockwise sweep from its stored `a` to `b`, degrees.
fn sweep_deg(sk: &Sketch, arc: usize) -> f64 {
let CurveKind::Arc { c, a, b } = sk.curves[arc].kind else { panic!() };
let (c, a, b) = (sk.points[c].pos, sk.points[a].pos, sk.points[b].pos);
((b - c).angle() - (a - c).angle()).rem_euclid(2.0 * PI).to_degrees()
}
/// Each line leaves the arc at its touch point along the arc's travel (a smooth path, not a
/// line cutting back through the arc).
fn lines_leave_the_arc(sk: &Sketch, arc: usize, lines: [usize; 2]) -> bool {
let CurveKind::Arc { c, a, b } = sk.curves[arc].kind else { panic!() };
lines.iter().all(|&l| {
let CurveKind::Line { a: la, b: lb } = sk.curves[l].kind else { panic!() };
let (p, far) = if la == a || la == b { (la, lb) } else { (lb, la) };
let outward = if p == b { 1.0 } else { -1.0 };
(sk.points[p].pos - sk.points[c].pos).perp().dot(sk.points[far].pos - sk.points[p].pos) * outward > 1e-6
})
}
#[test]
fn a_fillet_follows_its_corner_when_the_corner_mirrors() {
// Pin the vertical line's far end to a point below the horizontal line: the corner mirrors.
// The fillet is the quarter arc on the other side, not a line through its arc or a
// three-quarter arc.
let (mut sk, l1, arc, l2, _, far2) = filleted_corner();
let q = sk.add_point(v(40.0, -20.0), None).unwrap();
sk.points[q].fixed = true;
sk.add_constraint(ConstraintKind::Coincident { p: far2, q }, None).unwrap();
let rep = solve(&mut sk);
assert!(rep.ok(), "{rep:?}");
assert!((sweep_deg(&sk, arc) - 90.0).abs() < 1e-6, "sweep {}", sweep_deg(&sk, arc));
assert!(lines_leave_the_arc(&sk, arc, [l1, l2]));
assert!(sk.center(arc).unwrap().dist(v(35.0, 5.0)) < 1e-6, "{:?}", sk.center(arc));
let (_, q1) = line_pts(&sk, l1);
let (p2, _) = line_pts(&sk, l2);
assert!(q1.dist(v(35.0, 10.0)) < 1e-6 && p2.dist(v(40.0, 5.0)) < 1e-6, "{q1:?} {p2:?}");
// Dragged back up (a move against the positions before it), it mirrors again.
sk.constraints.retain(|c| !matches!(c.kind, ConstraintKind::Coincident { .. }));
let reference: Vec<Vec2> = sk.points.iter().map(|p| p.pos).collect();
sk.points[far2].pos = v(40.0, 30.0);
let rep = solve_with(&mut sk, &Hold { points: vec![far2], radii: Vec::new() }, Some(&reference), Analysis::Full, None);
assert!(rep.ok(), "{rep:?}");
assert!((sweep_deg(&sk, arc) - 90.0).abs() < 1e-6, "sweep {}", sweep_deg(&sk, arc));
assert!(lines_leave_the_arc(&sk, arc, [l1, l2]));
assert!(sk.center(arc).unwrap().dist(v(35.0, 15.0)) < 1e-6, "{:?}", sk.center(arc));
}
#[test]
fn a_fillet_folded_through_flat_is_refused_like_a_fold() {
// Without the horizontal and vertical, the second line turns about the corner: the arc
// shrinks towards flat (the lines collinear) and the lines keep leaving it. Past flat the
// only arc left is the loop the other way round; that step is refused as a fold and the
// sketch stays where it was.
let (mut sk, l1, arc, l2, _, far2) = filleted_corner();
sk.constraints.retain(|c| !matches!(c.kind, ConstraintKind::Horizontal { .. } | ConstraintKind::Vertical { .. }));
let hold = Hold { points: vec![far2], radii: Vec::new() };
let mut refused = None;
for step in 1..=16 {
let t = step as f64 / 16.0 * PI;
let reference: Vec<Vec2> = sk.points.iter().map(|p| p.pos).collect();
// From straight up, round through the right towards straight down, 15 mm from the corner.
sk.points[far2].pos = v(40.0, 10.0) + Vec2::from_angle(FRAC_PI_2 - t) * 15.0;
let rep = solve_with(&mut sk, &hold, Some(&reference), Analysis::Full, None);
if !rep.ok() {
assert!(!rep.folded.is_empty(), "step {step}: {rep:?}");
let stays = sk.points.iter().enumerate().filter(|(i, _)| *i != far2).all(|(i, p)| p.pos == reference[i]);
assert!(stays, "step {step}: the sketch stays");
refused = Some(step);
break;
}
let sweep = sweep_deg(&sk, arc);
assert!(sweep < 90.0 + 1e-6, "step {step}: sweep {sweep}");
assert!(lines_leave_the_arc(&sk, arc, [l1, l2]), "step {step}: sweep {sweep}");
}
assert!(refused.is_some_and(|s| (7..=9).contains(&s)), "refused at {refused:?}");
}