diff --git a/crates/engine/src/cmd/sketch_drag.rs b/crates/engine/src/cmd/sketch_drag.rs index c1c8c851d9..e020b6fc68 100644 --- a/crates/engine/src/cmd/sketch_drag.rs +++ b/crates/engine/src/cmd/sketch_drag.rs @@ -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), diff --git a/crates/engine/src/cmd/sketch_modify_tests.rs b/crates/engine/src/cmd/sketch_modify_tests.rs index 0936f07880..acf99057f0 100644 --- a/crates/engine/src/cmd/sketch_modify_tests.rs +++ b/crates/engine/src/cmd/sketch_modify_tests.rs @@ -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")); +} diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 6040ef6ae9..b0b986c2d6 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -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}; diff --git a/crates/sketch/src/solver.rs b/crates/sketch/src/solver.rs index 6ab81556b5..2c4d371e84 100644 --- a/crates/sketch/src/solver.rs +++ b/crates/sketch/src/solver.rs @@ -1857,7 +1857,7 @@ fn solve_from( old_values: Option<&[Option]>, 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 { + 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)>, +} + +/// 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 { + let swept: std::collections::BTreeSet = 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 = 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), +} + +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), 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]>, + budget: &mut Budget, +) -> SolveReport { + let stored: Vec = 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 = 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 = 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::>()).unwrap_or_default()) + .collect(); + let folded: Vec = 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, diff --git a/crates/sketch/src/tests.rs b/crates/sketch/src/tests.rs index d969f0fd6a..29a58496ed 100644 --- a/crates/sketch/src/tests.rs +++ b/crates/sketch/src/tests.rs @@ -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 = 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 = 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:?}"); +}