Files
solidcraft-upstream/crates/sketch/src/tests.rs
T
bherbruckandClaude Opus 5.5 d019f2d5bf Straight edges stay one edge after cuts (#1049) (#1101)
* Straight edges stay one edge after cuts (#1049)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Robustness goldens: cuts join straight edges along untouched walls (#1049)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fmt

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Robustness goldens note: what each changed seed shows when replayed on both builds

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-14 10:51:07 -04:00

924 lines
45 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::f64::consts::{FRAC_PI_2, PI};
use solidcraft_geom::Vec2;
use crate::*;
pub(crate) fn v(x: f64, y: f64) -> Vec2 {
Vec2::new(x, y)
}
/// Deterministic pseudo-random numbers for the solver harnesses.
pub(crate) struct Lcg(pub u64);
impl Lcg {
pub(crate) fn next(&mut self) -> f64 {
self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(self.0 >> 11) as f64 / (1u64 << 53) as f64
}
pub(crate) fn range(&mut self, lo: f64, hi: f64) -> f64 {
lo + self.next() * (hi - lo)
}
/// ±1, evenly.
pub(crate) fn sign(&mut self) -> f64 {
if self.next() < 0.5 { 1.0 } else { -1.0 }
}
}
/// Run `f` with the constructive seed on or off, without the seed-on/seed-off comparison (for
/// tests that measure the difference).
pub(crate) fn seeded<T>(on: bool, f: impl FnOnce() -> T) -> T {
use crate::construct::switch;
switch::without_differential(|| if on { f() } else { switch::without_seed(f) })
}
/// The points that are constants for a plain solve.
pub(crate) fn given(sk: &Sketch) -> Vec<bool> {
crate::solver::held_points(sk, &Hold::default())
}
/// The constructive seed of a plain solve.
pub(crate) fn seed_of(sk: &Sketch) -> crate::construct::Seed {
crate::construct::seed(sk, &given(sk), None, true, &crate::solver::points_on_curves(sk), &mut crate::decompose::Budget::full())
}
/// Rough rectangle from four lines sharing corner points.
fn rect(sk: &mut Sketch, p: [Vec2; 4]) -> [usize; 4] {
let a = sk.add_point(p[0], None).unwrap();
let b = sk.add_point(p[1], None).unwrap();
let c = sk.add_point(p[2], None).unwrap();
let d = sk.add_point(p[3], None).unwrap();
[
sk.add_line_pts(a, b, None).unwrap(),
sk.add_line_pts(b, c, None).unwrap(),
sk.add_line_pts(c, d, None).unwrap(),
sk.add_line_pts(d, a, None).unwrap(),
]
}
fn line_pts(sk: &Sketch, l: usize) -> (Vec2, Vec2) {
match sk.curves[l].kind {
CurveKind::Line { a, b } => (sk.points[a].pos, sk.points[b].pos),
_ => panic!(),
}
}
#[test]
fn fully_constrained_rectangle() {
let mut sk = Sketch::new();
let l = rect(&mut sk, [v(0.3, -0.2), v(38.0, 1.0), v(41.0, 29.0), v(-1.0, 31.0)]);
use ConstraintKind::*;
sk.add_constraint(Horizontal { l: l[0] }, None).unwrap();
sk.add_constraint(Horizontal { l: l[2] }, None).unwrap();
sk.add_constraint(Vertical { l: l[1] }, None).unwrap();
sk.add_constraint(Vertical { l: l[3] }, None).unwrap();
let (a, _) = match sk.curves[l[0]].kind {
CurveKind::Line { a, b } => (a, b),
_ => panic!(),
};
sk.add_constraint(Coincident { p: a, q: 0 }, None).unwrap();
sk.add_constraint(Length { l: l[0], value: 40.0 }, Some("d1".into())).unwrap();
sk.add_constraint(Length { l: l[1], value: 30.0 }, Some("d2".into())).unwrap();
let rep = solve(&mut sk);
assert!(rep.ok(), "{rep:?}");
assert_eq!(rep.dof, 0, "{rep:?}");
assert!(rep.point_determined.iter().all(|d| *d));
let (p0, p1) = line_pts(&sk, l[0]);
assert!(p0.dist(v(0.0, 0.0)) < 1e-7, "{p0:?}");
assert!(p1.dist(v(40.0, 0.0)) < 1e-7, "{p1:?}");
let (_, p2) = line_pts(&sk, l[1]);
assert!(p2.dist(v(40.0, 30.0)) < 1e-7, "{p2:?}");
// Change a dimension and re-solve.
sk.constraints[5].kind.set_value(55.0);
assert!(solve(&mut sk).ok());
let (_, p1) = line_pts(&sk, l[0]);
assert!(p1.dist(v(55.0, 0.0)) < 1e-7);
}
#[test]
fn under_constrained_reports_dof() {
let mut sk = Sketch::new();
let l = sk.add_line(v(0.0, 0.0), v(10.0, 1.0), None, None, None).unwrap();
sk.add_constraint(ConstraintKind::Horizontal { l }, None).unwrap();
let rep = solve(&mut sk);
assert!(rep.ok());
assert_eq!(rep.dof, 3);
let (a, b) = line_pts(&sk, l);
assert!((a.y - b.y).abs() < 1e-9);
// Minimal movement: the line moved by about half a unit each.
assert!((a.y - 0.5).abs() < 0.05 && (b.y - 0.5).abs() < 0.05, "{a:?} {b:?}");
}
#[test]
fn conflicting_constraints_fail_and_restore() {
let mut sk = Sketch::new();
let l = sk.add_line(v(0.0, 0.0), v(10.0, 0.0), None, None, None).unwrap();
sk.add_constraint(ConstraintKind::Horizontal { l }, None).unwrap();
sk.add_constraint(ConstraintKind::Vertical { l }, None).unwrap();
sk.add_constraint(ConstraintKind::Length { l, value: 10.0 }, None).unwrap();
let before = sk.clone();
let rep = solve(&mut sk);
assert_eq!(rep.status, SolveStatus::Failed);
assert!(!rep.failing.is_empty());
assert_eq!(sk.points, before.points);
}
#[test]
fn circles_tangent_equal_concentric() {
let mut sk = Sketch::new();
let c1 = sk.add_circle(v(0.0, 0.0), 5.0, Some(0), None).unwrap();
let c2 = sk.add_circle(v(12.0, 1.0), 3.0, None, None).unwrap();
use ConstraintKind::*;
sk.add_constraint(Radius { c: c1, value: 10.0 }, None).unwrap();
sk.add_constraint(Equal { a: c1, b: c2 }, None).unwrap();
sk.add_constraint(Tangent { a: c1, b: c2 }, None).unwrap();
let rep = solve(&mut sk);
assert!(rep.ok(), "{rep:?}");
assert!((sk.radius(c2).unwrap() - 10.0).abs() < 1e-7);
assert!((sk.center(c2).unwrap().len() - 20.0).abs() < 1e-7);
let c3 = sk.add_circle(v(3.0, 3.0), 2.0, None, None).unwrap();
sk.add_constraint(Concentric { a: c3, b: c2 }, None).unwrap();
assert!(solve(&mut sk).ok());
assert!(sk.center(c3).unwrap().dist(sk.center(c2).unwrap()) < 1e-7);
}
#[test]
fn line_tangent_to_circle_and_angles() {
let mut sk = Sketch::new();
let c = sk.add_circle(v(0.0, 0.0), 10.0, Some(0), None).unwrap();
use ConstraintKind::*;
sk.add_constraint(Radius { c, value: 10.0 }, None).unwrap();
let l = sk.add_line(v(-20.0, 12.0), v(20.0, 11.0), None, None, None).unwrap();
sk.add_constraint(Tangent { a: l, b: c }, None).unwrap();
sk.add_constraint(Horizontal { l }, None).unwrap();
assert!(solve(&mut sk).ok());
let (a, _) = line_pts(&sk, l);
assert!((a.y.abs() - 10.0).abs() < 1e-7, "{a:?}");
let m = sk.add_line(v(0.0, 0.0), v(10.0, 3.0), Some(0), None, None).unwrap();
sk.add_constraint(Angle { a: l, b: m, value: PI / 6.0, flip: false }, None).unwrap();
sk.add_constraint(Length { l: m, value: 8.0 }, None).unwrap();
assert!(solve(&mut sk).ok());
let (p, q) = line_pts(&sk, m);
let (la, lb) = line_pts(&sk, l);
assert!(((q - p).angle() - (lb - la).angle() - PI / 6.0).abs() < 1e-7, "{la:?} {lb:?} {p:?} {q:?}");
let n2 = sk.add_line(v(1.0, 1.0), v(2.0, 5.0), None, None, None).unwrap();
sk.add_constraint(Perpendicular { a: m, b: n2 }, None).unwrap();
sk.add_constraint(Parallel { a: n2, b: n2 }, None).unwrap();
assert!(solve(&mut sk).ok());
let (p2, q2) = line_pts(&sk, n2);
assert!(((q2 - p2).angle() - (PI / 6.0 + FRAC_PI_2)).abs() < 1e-6 || ((q2 - p2).angle() - (PI / 6.0 - FRAC_PI_2)).abs() < 1e-6);
}
#[test]
fn midpoint_symmetry_point_on_curve_arc() {
let mut sk = Sketch::new();
use ConstraintKind::*;
let l = sk.add_line(v(-5.0, 0.0), v(5.0, 0.0), None, None, None).unwrap();
let p = sk.add_point(v(1.0, 1.0), None).unwrap();
sk.add_constraint(Midpoint { p, l }, None).unwrap();
let a = sk.add_arc(v(0.0, 0.0), v(4.0, 0.0), v(0.0, 4.0), [None, None, None], None).unwrap();
sk.add_constraint(Radius { c: a, value: 6.0 }, None).unwrap();
let q = sk.add_point(v(3.0, 3.0), None).unwrap();
sk.add_constraint(PointOnCurve { p: q, c: a }, None).unwrap();
let s1 = sk.add_point(v(-2.0, 1.0), None).unwrap();
let s2 = sk.add_point(v(3.0, -1.5), None).unwrap();
let axis = sk.add_line(v(0.0, -10.0), v(0.0, 10.0), None, None, None).unwrap();
sk.add_constraint(Vertical { l: axis }, None).unwrap();
sk.add_constraint(Symmetric { p: s1, q: s2, l: axis }, None).unwrap();
let rep = solve(&mut sk);
assert!(rep.ok(), "{rep:?}");
let (la, lb) = line_pts(&sk, l);
assert!(sk.points[p].pos.dist((la + lb) * 0.5) < 1e-7);
assert!((sk.radius(a).unwrap() - 6.0).abs() < 1e-7);
let c = sk.center(a).unwrap();
assert!((sk.points[q].pos.dist(c) - 6.0).abs() < 1e-7);
let (ax, _) = line_pts(&sk, axis);
assert!((sk.points[s1].pos.x + sk.points[s2].pos.x - 2.0 * ax.x).abs() < 1e-7);
assert!((sk.points[s1].pos.y - sk.points[s2].pos.y).abs() < 1e-7);
}
#[test]
fn profiles_rectangle_with_hole_and_split() {
let mut sk = Sketch::new();
rect(&mut sk, [v(0.0, 0.0), v(40.0, 0.0), v(40.0, 30.0), v(0.0, 30.0)]);
sk.add_circle(v(20.0, 15.0), 5.0, None, None).unwrap();
let ps = find_profiles(&sk);
assert_eq!(ps.len(), 2, "{ps:?}");
let big = ps.iter().find(|p| p.region.holes.len() == 1).unwrap();
assert!((big.area - (1200.0 - PI * 25.0)).abs() < 1e-6);
let disc = ps.iter().find(|p| p.region.holes.is_empty()).unwrap();
assert!((disc.area - PI * 25.0).abs() < 1e-6);
assert_eq!(big.hole_curves.len(), 1);
// A line across the rectangle splits it into two regions (with the hole in one).
let mut sk2 = Sketch::new();
let l = rect(&mut sk2, [v(0.0, 0.0), v(40.0, 0.0), v(40.0, 30.0), v(0.0, 30.0)]);
let (b0, _) = line_pts(&sk2, l[0]);
let _ = b0;
let pa = sk2.add_point(v(10.0, 0.0), None).unwrap();
let pb = sk2.add_point(v(10.0, 30.0), None).unwrap();
// Re-split bottom and top edges at x = 10 by rebuilding the sketch as 7 lines.
let mut s3 = Sketch::new();
let pts = [v(0.0, 0.0), v(10.0, 0.0), v(40.0, 0.0), v(40.0, 30.0), v(10.0, 30.0), v(0.0, 30.0)];
let ids: Vec<usize> = pts.iter().map(|p| s3.add_point(*p, None).unwrap()).collect();
for i in 0..6 {
s3.add_line_pts(ids[i], ids[(i + 1) % 6], None).unwrap();
}
s3.add_line_pts(ids[1], ids[4], None).unwrap();
let _ = (pa, pb);
let ps = find_profiles(&s3);
assert_eq!(ps.len(), 2);
let mut areas: Vec<f64> = ps.iter().map(|p| p.area).collect();
areas.sort_by(f64::total_cmp);
assert!((areas[0] - 300.0).abs() < 1e-9 && (areas[1] - 900.0).abs() < 1e-9, "{areas:?}");
}
#[test]
fn profile_with_arcs_and_construction_ignored() {
// Slot: two lines and two half arcs.
let mut sk = Sketch::new();
let p = [v(0.0, 0.0), v(20.0, 0.0), v(20.0, 10.0), v(0.0, 10.0)];
let ids: Vec<usize> = p.iter().map(|q| sk.add_point(*q, None).unwrap()).collect();
sk.add_line_pts(ids[0], ids[1], None).unwrap();
sk.add_arc(v(20.0, 5.0), p[1], p[2], [None, Some(ids[1]), Some(ids[2])], None).unwrap();
sk.add_line_pts(ids[2], ids[3], None).unwrap();
sk.add_arc(v(0.0, 5.0), p[3], p[0], [None, Some(ids[3]), Some(ids[0])], None).unwrap();
let c = sk.add_line(v(-5.0, -5.0), v(30.0, 20.0), None, None, None).unwrap();
sk.curves[c].construction = true;
let ps = find_profiles(&sk);
assert_eq!(ps.len(), 1, "{ps:?}");
assert!((ps[0].area - (200.0 + PI * 25.0)).abs() < 1e-6, "{}", ps[0].area);
assert_eq!(ps[0].outer_curves.len(), 4);
}
#[test]
fn removal_and_references() {
let mut sk = Sketch::new();
let l1 = sk.add_line(v(0.0, 0.0), v(1.0, 0.0), None, None, Some("l1")).unwrap();
let e = sk.resolve_point("l1.end").unwrap();
let l2 = sk.add_line(v(1.0, 0.0), v(1.0, 1.0), Some(e), None, Some("l2")).unwrap();
sk.add_constraint(ConstraintKind::Horizontal { l: l1 }, None).unwrap();
sk.add_constraint(ConstraintKind::Vertical { l: l2 }, None).unwrap();
assert!(sk.add_line(v(0.0, 0.0), v(1.0, 0.0), None, None, Some("l1")).is_err());
sk.remove_curves(&[l1]);
assert_eq!(sk.curves.len(), 1);
assert_eq!(sk.constraints.len(), 1);
assert!(sk.resolve_point("l2.start").is_some());
assert!(sk.resolve_point("l1.start").is_none());
assert_eq!(sk.points.len(), 3, "origin + l2's two points");
assert!(solve(&mut sk).ok());
let json = serde_json::to_string(&sk).unwrap();
let back: Sketch = serde_json::from_str(&json).unwrap();
assert_eq!(back, sk);
}
#[test]
fn hostile_values_rejected() {
let mut sk = Sketch::new();
assert!(sk.add_point(v(f64::NAN, 0.0), None).is_err());
assert!(sk.add_circle(v(0.0, 0.0), -1.0, None, None).is_err());
assert!(sk.add_circle(v(0.0, 0.0), f64::INFINITY, None, None).is_err());
assert!(sk.add_constraint(ConstraintKind::Horizontal { l: 99 }, None).is_err());
let c = sk.add_circle(v(0.0, 0.0), 1.0, None, None).unwrap();
assert!(sk.add_constraint(ConstraintKind::Horizontal { l: c }, None).is_err());
assert!(sk.add_constraint(ConstraintKind::Radius { c, value: f64::NAN }, None).is_err());
assert!(sk.add_constraint(ConstraintKind::Radius { c, value: -2.0 }, None).is_err());
}
#[test]
fn profiles_split_at_crossings_and_touch_points() {
// A line across a circle: two half discs.
let mut sk = Sketch::new();
sk.add_circle(v(0.0, 0.0), 10.0, None, None).unwrap();
sk.add_line(v(-15.0, 0.0), v(15.0, 0.0), None, None, None).unwrap();
let ps = find_profiles(&sk);
assert_eq!(ps.len(), 2, "{ps:?}");
for p in &ps {
assert!((p.area - PI * 50.0).abs() < 1e-6, "{}", p.area);
}
// Belt: two circles joined by two tangent lines whose ends sit on the circles.
let mut b = Sketch::new();
let c1 = b.add_circle(v(0.0, 0.0), 20.0, Some(0), None).unwrap();
let c2 = b.add_circle(v(50.0, 0.0), 10.0, None, None).unwrap();
let l1 = b.add_line(v(3.0, 19.0), v(47.0, 13.0), None, None, None).unwrap();
let l2 = b.add_line(v(1.0, -22.0), v(44.0, -8.0), None, None, None).unwrap();
use ConstraintKind::*;
for (l, end, c) in [(l1, "start", c1), (l1, "end", c2), (l2, "start", c1), (l2, "end", c2)] {
let p = b.resolve_point(&format!("{}.{end}", b.curves[l].id)).unwrap();
b.add_constraint(PointOnCurve { p, c }, None).unwrap();
}
for (l, c) in [(l1, c1), (l1, c2), (l2, c1), (l2, c2)] {
b.add_constraint(Tangent { a: l, b: c }, None).unwrap();
}
b.add_constraint(Radius { c: c1, value: 20.0 }, None).unwrap();
b.add_constraint(Radius { c: c2, value: 10.0 }, None).unwrap();
let r = solve(&mut b);
assert!(r.ok(), "{r:?}");
let ps = find_profiles(&b);
assert_eq!(ps.len(), 3, "two discs and the band between them: {:?}", ps.iter().map(|p| p.area).collect::<Vec<_>>());
let total: f64 = ps.iter().map(|p| p.area).sum();
assert!(total > PI * 500.0, "{total}");
}
/// Curves made tangent by the solver touch within rounding noise, either side of exact (#588):
/// the touch still splits them. Lines running past their tangent points close the band between
/// two circles, and a square's side tangent to a circle closes the square off from the disc.
#[test]
fn solver_tangencies_split_profiles() {
use ConstraintKind::*;
// Two fixed circles and two free lines, longer than the band, made tangent to both.
for (r1, r2, d) in [(15.0, 8.0, 40.0), (20.0, 10.0, 50.0), (7.3, 7.3, 21.1)] {
let mut b = Sketch::new();
let c1 = b.add_circle(v(0.0, 0.0), r1, None, None).unwrap();
let c2 = b.add_circle(v(d, 0.0), r2, None, None).unwrap();
let l1 = b.add_line(v(-r1, r1 + 2.0), v(d + r2 + 5.0, r2 - 1.0), None, None, None).unwrap();
let l2 = b.add_line(v(-r1, -r1 - 2.0), v(d + r2 + 5.0, 1.0 - r2), None, None, None).unwrap();
for c in [c1, c2] {
let p = b.resolve_point(&format!("{}.center", b.curves[c].id)).unwrap();
b.add_constraint(Fix { p }, None).unwrap();
}
b.add_constraint(Radius { c: c1, value: r1 }, None).unwrap();
b.add_constraint(Radius { c: c2, value: r2 }, None).unwrap();
for (l, c) in [(l1, c1), (l1, c2), (l2, c1), (l2, c2)] {
b.add_constraint(Tangent { a: l, b: c }, None).unwrap();
}
assert!(solve(&mut b).ok());
let ps = find_profiles(&b);
// The band: the trapezoid between the tangent points less the circular segments the
// circles take from it (as exact sums: the hull less the two discs).
let alpha = ((r1 - r2) / d).asin();
let tangent_len = (d * d - (r1 - r2).powi(2)).sqrt();
let hull = r1 * r1 * (PI / 2.0 + alpha) + r2 * r2 * (PI / 2.0 - alpha) + (r1 + r2) * tangent_len;
let total: f64 = ps.iter().map(|p| p.area).sum();
assert_eq!(ps.len(), 3, "r {r1}, {r2}: {:?}", ps.iter().map(|p| p.area).collect::<Vec<_>>());
assert!((total - hull).abs() < 1e-6 * hull, "r {r1}, {r2}: {total} vs {hull}");
}
// A square tangent to a circle inside it and outside it, the circle placed by the solver.
for (inside, centre) in [(true, v(5.0, 6.3)), (false, v(5.0, 16.7))] {
let mut s = Sketch::new();
let sides = [v(0.0, 0.0), v(10.0, 0.0), v(10.0, 10.0), v(0.0, 10.0)];
let mut lines = Vec::new();
for k in 0..4 {
lines.push(s.add_line(sides[k], sides[(k + 1) % 4], None, None, None).unwrap());
}
for k in 0..4 {
let (a, b) = (
s.resolve_point(&format!("{}.end", s.curves[lines[k]].id)).unwrap(),
s.resolve_point(&format!("{}.start", s.curves[lines[(k + 1) % 4]].id)).unwrap(),
);
s.add_constraint(Coincident { p: a, q: b }, None).unwrap();
s.add_constraint(Fix { p: a }, None).unwrap();
}
let c = s.add_circle(centre, 3.7, None, None).unwrap();
s.add_constraint(Radius { c, value: 3.7 }, None).unwrap();
s.add_constraint(Tangent { a: lines[2], b: c }, None).unwrap();
assert!(solve(&mut s).ok());
let ps = find_profiles(&s);
let disc = PI * 3.7 * 3.7;
let mut areas: Vec<f64> = ps.iter().map(|p| p.area).collect();
areas.sort_by(f64::total_cmp);
let want = if inside { [disc, 100.0 - disc] } else { [disc, 100.0] };
let mut want = want.to_vec();
want.sort_by(f64::total_cmp);
assert_eq!(areas.len(), 2, "inside {inside}: {areas:?}");
for (a, w) in areas.iter().zip(&want) {
assert!((a - w).abs() < 1e-6, "inside {inside}: {areas:?} vs {want:?}");
}
}
}
/// Sorted curve ids.
fn id_set(sk: &Sketch, curves: &[usize]) -> Vec<String> {
let mut ids: Vec<String> = curves.iter().map(|c| sk.curves[*c].id.clone()).collect();
ids.sort();
ids
}
/// A projected line of `a`–`b` in `sk`.
fn reference_line(sk: &mut Sketch, a: Vec2, b: Vec2) -> usize {
let link = sk.add_link(LinkKind::Project, LinkSource::SketchCurve { sketch: 1, curve: "edge".into() }, &[LinkGeom::Line(a, b)]).unwrap();
sk.link_curves(&link)[0]
}
/// #635: a side constrained onto a longer projected line lies on it only up to the solver's
/// round-off. Where they overlap they are one edge of the drawing, so the rectangle is still a
/// closed profile, and the curve that came first names that edge, as when the side lies on the
/// line exactly.
#[test]
fn a_side_on_a_projected_line_still_closes_its_rectangle() {
// Round-off left on the side's two ends (the first pair as solved in #635), and none.
for (y0, y1) in
[(1.6e-12, 2.2e-12), (2.2e-12, 1.6e-12), (-1e-12, 1e-12), (1e-12, -1e-12), (-3e-11, -1e-11), (-3e-10, 3e-10), (2e-10, 4e-10), (0.0, 0.0)]
{
for reference_first in [true, false] {
for (ra, rb) in [(v(40.0, 0.0), v(-10.0, 0.0)), (v(-10.0, 0.0), v(40.0, 0.0))] {
let mut sk = Sketch::new();
let early = reference_first.then(|| reference_line(&mut sk, ra, rb));
let sides = rect(&mut sk, [v(5.0, y0), v(25.0, y1), v(25.0, 15.0), v(5.0, 15.0)]);
let reference = early.unwrap_or_else(|| reference_line(&mut sk, ra, rb));
let case = format!("ends at y {y0}, {y1}; reference {} {ra:?} to {rb:?}", if reference_first { "first" } else { "last" });
let ps = find_profiles(&sk);
assert_eq!(ps.len(), 1, "{case}: {:?}", ps.iter().map(|p| (p.area, &p.outer_curves)).collect::<Vec<_>>());
assert!((ps[0].area - 300.0).abs() < 1e-6, "{case}: {}", ps[0].area);
let mut got = ps[0].outer_curves.clone();
got.sort();
let want = id_set(&sk, &[if reference_first { reference } else { sides[0] }, sides[1], sides[2], sides[3]]);
assert_eq!(got, want, "{case}");
// Whole curves make a lasting identity; a piece of the reference cut at the corners does not.
let whole = (!reference_first).then(|| (want, Vec::new()));
assert_eq!(ps[0].whole_curve_boundaries(), whole, "{case}");
}
}
}
}
/// A third curve meeting a side and the projected line under it would meet each a little apart:
/// a circle touching the line from inside the rectangle (it misses or crosses the side by the
/// round-off), and a line crossing both at a shallow angle (the crossings 3e-5 mm apart). It
/// meets both where it meets the first of them, so they still make one edge, whichever way the
/// side runs.
#[test]
fn a_curve_meeting_a_side_on_a_projected_line_meets_both_at_once() {
for y0 in [3e-10, -3e-10, 0.0] {
for reference_first in [true, false] {
for reversed in [false, true] {
let case = format!("side at y {y0}, reference {}, side reversed {reversed}", if reference_first { "first" } else { "last" });
let corners = |x1: f64| {
let (a, b) = if reversed { (x1, 5.0) } else { (5.0, x1) };
[v(a, y0), v(b, y0), v(b, 15.0), v(a, 15.0)]
};
let mut sk = Sketch::new();
let early = reference_first.then(|| reference_line(&mut sk, v(-10.0, 0.0), v(40.0, 0.0)));
rect(&mut sk, corners(25.0));
sk.add_circle(v(15.0, 5.0), 5.0, None, None).unwrap();
early.unwrap_or_else(|| reference_line(&mut sk, v(-10.0, 0.0), v(40.0, 0.0)));
let mut areas: Vec<f64> = find_profiles(&sk).iter().map(|p| p.area).collect();
areas.sort_by(f64::total_cmp);
assert_eq!(areas.len(), 2, "touching circle, {case}: {areas:?}");
assert!((areas[0] - 25.0 * PI).abs() < 1e-6 && (areas[1] - (300.0 - 25.0 * PI)).abs() < 1e-6, "touching circle, {case}: {areas:?}");
let mut sk = Sketch::new();
let early = reference_first.then(|| reference_line(&mut sk, v(-10.0, 0.0), v(440.0, 0.0)));
rect(&mut sk, corners(405.0));
// Through (200, 0), rising 1e-5 per mm: a sliver of 205 x 0.00205 / 2 under it.
sk.add_line(v(0.0, -0.002), v(430.0, 0.0023), None, None, None).unwrap();
early.unwrap_or_else(|| reference_line(&mut sk, v(-10.0, 0.0), v(440.0, 0.0)));
let mut areas: Vec<f64> = find_profiles(&sk).iter().map(|p| p.area).collect();
areas.sort_by(f64::total_cmp);
assert_eq!(areas.len(), 2, "shallow line, {case}: {areas:?}");
assert!((areas[0] - 0.210125).abs() < 1e-6 && (areas[1] - (6000.0 - 0.210125)).abs() < 1e-6, "shallow line, {case}: {areas:?}");
}
}
}
}
/// The same for drawn curves on drawn curves: two rectangles drawn side by side, the second's
/// side solved onto the first's; and a quarter disc whose arc is solved onto a projected circle.
#[test]
fn curves_solved_onto_each_other_keep_the_regions_on_both_sides() {
for (x0, x1) in [(1e-12, -1e-12), (-2e-12, -1e-12), (2.2e-12, 1.6e-12), (0.0, 0.0)] {
let mut sk = Sketch::new();
let a = rect(&mut sk, [v(0.0, 0.0), v(20.0, 0.0), v(20.0, 15.0), v(0.0, 15.0)]);
let b = rect(&mut sk, [v(20.0 + x0, 0.0), v(40.0, 0.0), v(40.0, 15.0), v(20.0 + x1, 15.0)]);
let ps = find_profiles(&sk);
let areas: Vec<f64> = ps.iter().map(|p| p.area).collect();
assert_eq!(ps.len(), 2, "{x0}, {x1}: {areas:?}");
assert!(areas.iter().all(|a| (a - 300.0).abs() < 1e-6), "{x0}, {x1}: {areas:?}");
// The shared side is the first rectangle's (it was there first).
let mut first = ps[0].outer_curves.clone();
first.sort();
assert_eq!(first, id_set(&sk, &a), "{x0}, {x1}");
let mut second = ps[1].outer_curves.clone();
second.sort();
assert_eq!(second, id_set(&sk, &[b[0], b[1], b[2], a[1]]), "{x0}, {x1}");
}
let hole = |sk: &mut Sketch| {
let link = sk
.add_link(LinkKind::Project, LinkSource::SketchCurve { sketch: 1, curve: "hole".into() }, &[LinkGeom::Circle(v(0.0, 0.0), 10.0)])
.unwrap();
sk.link_curves(&link)[0]
};
for (dx, dr) in [(1e-12, 2e-12), (-1e-12, -1e-12), (0.0, 0.0)] {
for circle_first in [true, false] {
let mut sk = Sketch::new();
let early = circle_first.then(|| hole(&mut sk));
let c = sk.add_point(v(dx, -dx), None).unwrap();
let (p0, p1) = (sk.add_point(v(10.0 + dr, 0.0), None).unwrap(), sk.add_point(v(0.0, 10.0 + dr), None).unwrap());
let arc = sk.add_arc(v(dx, -dx), v(10.0 + dr, 0.0), v(0.0, 10.0 + dr), [Some(c), Some(p0), Some(p1)], None).unwrap();
let (l0, l1) = (sk.add_line_pts(c, p0, None).unwrap(), sk.add_line_pts(p1, c, None).unwrap());
let circle = early.unwrap_or_else(|| hole(&mut sk));
let case = format!("{dx}, {dr}, circle {}", if circle_first { "first" } else { "last" });
let ps = find_profiles(&sk);
let areas: Vec<f64> = ps.iter().map(|p| p.area).collect();
assert_eq!(ps.len(), 2, "{case}: {areas:?}");
let quarter = ps.iter().find(|p| p.area < 100.0).unwrap();
assert!((quarter.area - 25.0 * PI).abs() < 1e-6, "{case}: {areas:?}");
// One piece each: round-off alone does not cut the arc where it crosses the circle.
let mut got = quarter.outer_curves.clone();
got.sort();
assert_eq!(got, id_set(&sk, &[if circle_first { circle } else { arc }, l0, l1]), "{case}");
let rest = ps.iter().find(|p| p.area > 100.0).unwrap();
assert!((rest.area - 75.0 * PI).abs() < 1e-6, "{case}: {areas:?}");
}
}
}
/// What does not run along one path stays apart: a chord and an arc bulging past it by about
/// the merge tolerance still bound their thin region, and two whole circles further apart than
/// it are still cut where they cross, into a lens and two crescents. Closer than it, their
/// halves are one path: one disc.
#[test]
fn near_overlaps_that_are_not_one_path_keep_their_regions() {
let s = 1.5e-5;
let r = (25.0 + s * s) / (2.0 * s);
let mut sk = Sketch::new();
let (a, b) = (sk.add_point(v(-5.0, 0.0), None).unwrap(), sk.add_point(v(5.0, 0.0), None).unwrap());
let chord = sk.add_line_pts(a, b, None).unwrap();
let arc = sk.add_arc(v(0.0, s - r), v(5.0, 0.0), v(-5.0, 0.0), [None, Some(b), Some(a)], None).unwrap();
let ps = find_profiles(&sk);
assert_eq!(ps.len(), 1, "{:?}", ps.iter().map(|p| p.area).collect::<Vec<_>>());
// Two thirds of chord times height, to well within the rounding of the area.
assert!((ps[0].area - 2.0 / 3.0 * 10.0 * s).abs() < 1e-8, "{}", ps[0].area);
let mut got = ps[0].outer_curves.clone();
got.sort();
assert_eq!(got, id_set(&sk, &[chord, arc]));
// The same with a free-form curve: a flat cubic (height s) over its chord. It takes part as
// sampled pieces, one of them on the chord, but its exact curve bounds the region.
let s = 8e-6;
let mut sk = Sketch::new();
let ids: Vec<usize> = [v(-5.0, 0.0), v(-5.0 / 3.0, 4.0 * s / 3.0), v(5.0 / 3.0, 4.0 * s / 3.0), v(5.0, 0.0)]
.iter()
.map(|p| sk.add_point(*p, None).unwrap())
.collect();
sk.add_curve(CurveKind::Spline { pts: ids.clone(), control: true, degree: 3, periodic: false }, None).unwrap();
sk.add_line_pts(ids[3], ids[0], None).unwrap();
let ps = find_profiles(&sk);
assert_eq!(ps.len(), 1, "{:?}", ps.iter().map(|p| p.area).collect::<Vec<_>>());
assert!((ps[0].area - 20.0 * s / 3.0).abs() < 1e-9, "{}", ps[0].area);
// An arc a little off a circle, more than the merge tolerance apart at its ends (so they
// don't cut the circle): the two still cut each other where they cross, and no profile is
// the uncut circle.
let mut sk = Sketch::new();
let circle = sk.add_circle(v(0.0, 0.0), 10.0, None, None).unwrap();
let (c, r) = (v(8e-6, 0.0), 10.0 + 6e-6);
let (p0, p1) = (c + Vec2::from_angle(PI / 4.0) * r, c + Vec2::from_angle(-PI / 4.0) * r);
let (a0, a1, tip) = (sk.add_point(p0, None).unwrap(), sk.add_point(p1, None).unwrap(), sk.add_point(v(30.0, 0.0), None).unwrap());
sk.add_arc(c, p0, p1, [None, Some(a0), Some(a1)], None).unwrap();
sk.add_line_pts(a1, tip, None).unwrap();
sk.add_line_pts(tip, a0, None).unwrap();
let ps = find_profiles(&sk);
let circle_id = sk.curves[circle].id.clone();
assert!(ps.iter().all(|p| p.outer_curves != [circle_id.clone()] && p.whole_curve_boundaries().is_none()), "{ps:?}");
// Profiles don't overlap: together they cover the disc and the arc's region once, that is the
// disc and the triangle to the tip less the disc's segment inside it (to within the slivers
// between arc and circle).
let beyond = 0.5 * (p0 - v(30.0, 0.0)).cross(p1 - v(30.0, 0.0)).abs() - 50.0 * (PI / 2.0 - 1.0);
let covered = ps.iter().map(|p| p.area).sum::<f64>();
assert!((covered - PI * 100.0 - beyond).abs() < 1e-2, "{:?}", ps.iter().map(|p| p.area).collect::<Vec<_>>());
let r = 10.0_f64;
for (d, regions) in [(1e-3, 3), (1e-4, 3), (8e-6, 1)] {
let mut sk = Sketch::new();
let first = sk.add_circle(v(0.0, 0.0), r, None, None).unwrap();
sk.add_circle(v(d, 0.0), r, None, None).unwrap();
let ps = find_profiles(&sk);
let areas: Vec<f64> = ps.iter().map(|p| p.area).collect();
assert_eq!(ps.len(), regions, "{d}: {areas:?}");
let lens = 2.0 * r * r * (d / (2.0 * r)).acos() - d / 2.0 * (4.0 * r * r - d * d).sqrt();
let union = if regions == 1 { PI * r * r } else { 2.0 * PI * r * r - lens };
assert!((areas.iter().sum::<f64>() - union).abs() < 1e-6, "{d}: {areas:?}");
// Cut where they cross: no whole-circle identity.
assert!(ps.iter().all(|p| p.whole_curve_boundaries().is_none()), "{d}");
if regions == 1 {
assert_eq!(ps[0].outer_curves, vec![sk.curves[first].id.clone(); 2], "{d}: the first circle's halves");
}
}
}
/// Curves count as lying on one another only within the merge tolerance of the one carrying
/// them, not along a chain: an arc between two whole circles 1.6e-5 apart (each within 8e-6 of
/// it) does not stop the circles cutting each other where they cross.
#[test]
fn curves_lying_on_one_another_do_not_chain_past_the_tolerance() {
let (d, r) = (16e-6, 10.0_f64);
let mut sk = Sketch::new();
sk.add_circle(v(0.0, 0.0), r, None, None).unwrap();
sk.add_circle(v(d, 0.0), r, None, None).unwrap();
let c = v(d / 2.0, 0.0);
let (p0, p1) = (c + Vec2::from_angle(PI / 4.0) * r, c + Vec2::from_angle(3.0 * PI / 4.0) * r);
sk.add_arc(c, p0, p1, [None, None, None], None).unwrap();
let areas: Vec<f64> = find_profiles(&sk).iter().map(|p| p.area).collect();
let lens = 2.0 * r * r * (d / (2.0 * r)).acos() - d / 2.0 * (4.0 * r * r - d * d).sqrt();
// The discs' union once, not each disc whole.
assert!((areas.iter().sum::<f64>() - (2.0 * PI * r * r - lens)).abs() < 1e-6, "{areas:?}");
// Two dividers of a square, each carried by a short line 8e-6 off it that does not reach
// their crossing: they still cross at one node, and the square has four quarters.
let mut sk = Sketch::new();
sk.add_line(v(-20.0, 0.0), v(-10.0, 0.0), None, None, None).unwrap();
sk.add_line(v(0.0, -20.0), v(0.0, -10.0), None, None, None).unwrap();
rect(&mut sk, [v(-15.0, -15.0), v(15.0, -15.0), v(15.0, 15.0), v(-15.0, 15.0)]);
sk.add_line(v(-15.0, 8e-6), v(15.0, 8e-6), None, None, None).unwrap();
sk.add_line(v(8e-6, -15.0), v(8e-6, 15.0), None, None, None).unwrap();
let areas: Vec<f64> = find_profiles(&sk).iter().map(|p| p.area).collect();
assert_eq!(areas.len(), 4, "{areas:?}");
assert!((areas.iter().sum::<f64>() - 900.0).abs() < 1e-6, "{areas:?}");
}
#[test]
fn merging_adjacent_regions() {
let a =
solidcraft_geom::Region2 { outer: solidcraft_geom::Loop2::polygon(&[v(0.0, 0.0), v(10.0, 0.0), v(10.0, 10.0), v(0.0, 10.0)]), holes: vec![] };
let b = solidcraft_geom::Region2 {
outer: solidcraft_geom::Loop2::polygon(&[v(10.0, 0.0), v(20.0, 0.0), v(20.0, 10.0), v(10.0, 10.0)]),
holes: vec![],
};
let c = solidcraft_geom::Region2 { outer: solidcraft_geom::Loop2::polygon(&[v(50.0, 0.0), v(60.0, 0.0), v(60.0, 10.0)]), holes: vec![] };
let m = merge_regions(&[a, b, c]);
assert_eq!(m.len(), 2);
let mut areas: Vec<f64> = m.iter().map(|r| r.area()).collect();
areas.sort_by(f64::total_cmp);
assert!((areas[0] - 50.0).abs() < 1e-9 && (areas[1] - 200.0).abs() < 1e-9, "{areas:?}");
// The two squares make one rectangle: the outline isn't left split where the shared edge was.
let sides = m.iter().map(|r| r.outer.segs.len()).collect::<Vec<_>>();
assert!(sides.contains(&4) && sides.contains(&3), "{sides:?}");
}
/// A sketch holding `text` the way `sketch.text` makes it: one text link of the built-in font's
/// outlines.
fn text_sketch(text: &str, at: Vec2, height: f64) -> Sketch {
let mut sk = Sketch::new();
let source = LinkSource::Text { text: text.into(), at, height, angle: 0.0, font: None, style: Default::default() };
sk.add_link(LinkKind::Text, source, &text_geometry(text, at, height, 0.0).unwrap()).unwrap();
sk
}
fn sorted_areas(ps: &[Profile]) -> Vec<f64> {
let mut a: Vec<f64> = ps.iter().map(|p| p.area).collect();
a.sort_by(f64::total_cmp);
a
}
fn assert_areas(got: &[f64], mut want: Vec<f64>) {
want.sort_by(f64::total_cmp);
assert_eq!(got.len(), want.len(), "{got:?} against {want:?}");
for (g, w) in got.iter().zip(&want) {
assert!((g - w).abs() < 2e-3, "{got:?} against {want:?}");
}
}
#[test]
fn text_counters_are_holes_of_their_letters_not_profiles_of_their_own() {
// Areas in mm² at a capital height of 10 mm, summed from the Liberation Sans contours (shoelace
// formula, quadratic pieces flattened finely): a glyph's outer contour, then its counters.
// O: 83.3133 - 47.4584 A: 39.2159 - 9.0647 B: 68.9283 - 12.9336 - 16.6277
// 8: 56.8394 - 10.6451 - 13.7347
let (o, o_counter) = (83.3133 - 47.4584, 47.4584);
let (a, a_counter) = (39.2159 - 9.0647, 9.0647);
let (b, b_counters) = (68.9283 - 12.9336 - 16.6277, [12.9336, 16.6277]);
let (eight, eight_counters) = (56.8394 - 10.6451 - 13.7347, [10.6451, 13.7347]);
let check = |text: &str, letters: Vec<f64>, counters: Vec<f64>| {
let sk = text_sketch(text, Vec2::ZERO, 10.0);
let all = find_drawn_profiles(&sk);
// Every counter is a closed region of its own, next to its letter with the hole in it...
assert_areas(&sorted_areas(&all), letters.iter().chain(&counters).copied().collect());
// ...and "all profiles" of text means the letters.
assert_areas(&sorted_areas(&without_text_counters(&sk, all)), letters);
};
check("O", vec![o], vec![o_counter]);
check("8", vec![eight], eight_counters.to_vec());
check("AB", vec![a, b], [vec![a_counter], b_counters.to_vec()].concat());
// A letter without a counter is untouched.
check("I", vec![13.5557], vec![]);
}
#[test]
fn text_beside_other_curves_is_drawn_geometry() {
// A 30 x 20 rectangle with an "I" (a 10 mm tall bar, 13.5557 mm² in Liberation Sans) beside it.
let mut sk = text_sketch("I", v(40.0, 0.0), 10.0);
for (p, q) in [((0.0, 0.0), (30.0, 0.0)), ((30.0, 0.0), (30.0, 20.0)), ((30.0, 20.0), (0.0, 20.0)), ((0.0, 20.0), (0.0, 0.0))] {
sk.add_line(v(p.0, p.1), v(q.0, q.1), None, None, None).unwrap();
}
assert_areas(&sorted_areas(&find_drawn_profiles(&sk)), vec![13.5557, 600.0]);
// Projected reference geometry still doesn't count as drawn.
let square = |s: f64| {
vec![
LinkGeom::Line(v(100.0, 0.0), v(100.0 + s, 0.0)),
LinkGeom::Line(v(100.0 + s, 0.0), v(100.0 + s, s)),
LinkGeom::Line(v(100.0 + s, s), v(100.0, s)),
LinkGeom::Line(v(100.0, s), v(100.0, 0.0)),
]
};
sk.add_link(LinkKind::Project, LinkSource::Origin, &square(5.0)).unwrap();
assert_eq!(find_profiles(&sk).len(), 3);
assert_areas(&sorted_areas(&find_drawn_profiles(&sk)), vec![13.5557, 600.0]);
}
#[test]
fn a_letter_merged_with_its_counter_is_the_filled_letter() {
// The ring of an O and its counter share the counter's two conics; merged, they cancel along it.
let sk = text_sketch("O", Vec2::ZERO, 10.0);
let regions: Vec<_> = find_profiles(&sk).into_iter().map(|p| p.region).collect();
assert_eq!(regions.len(), 2);
let merged = merge_regions(&regions);
assert_eq!(merged.len(), 1, "{} regions", merged.len());
assert!(merged[0].holes.is_empty(), "{} holes", merged[0].holes.len());
assert!((merged[0].area() - 83.3133).abs() < 2e-3, "{}", merged[0].area());
}
#[test]
fn links_update_in_place_rebuild_and_break() {
use crate::{LinkGeom, LinkKind, LinkSource};
let mut sk = Sketch::new();
let src = LinkSource::Origin;
let sq = |s: f64| {
vec![
LinkGeom::Line(v(0.0, 0.0), v(s, 0.0)),
LinkGeom::Line(v(s, 0.0), v(s, s)),
LinkGeom::Line(v(s, s), v(0.0, s)),
LinkGeom::Line(v(0.0, s), v(0.0, 0.0)),
]
};
let j = sk.add_link(LinkKind::Project, src, &sq(10.0)).unwrap();
assert_eq!(sk.link_curves(&j).len(), 4);
// Corners are shared: 4 points, all fixed.
let pts = sk.link_points(&j);
assert_eq!(pts.len(), 4);
assert!(pts.iter().all(|p| sk.points[*p].fixed));
// A free line dimensioned to a projected corner keeps its constraint across an update.
let l = sk.add_line(v(3.0, 3.0), v(5.0, 7.0), None, None, None).unwrap();
let (la, _) = match sk.curves[l].kind {
CurveKind::Line { a, b } => (a, b),
_ => panic!(),
};
sk.add_constraint(ConstraintKind::Coincident { p: la, q: pts[2] }, None).unwrap();
let ids: Vec<String> = sk.link_curves(&j).iter().map(|c| sk.curves[*c].id.clone()).collect();
assert!(!sk.update_link(&j, &sq(20.0)).unwrap(), "same shape updates in place");
let ids2: Vec<String> = sk.link_curves(&j).iter().map(|c| sk.curves[*c].id.clone()).collect();
assert_eq!(ids, ids2);
assert!(solve(&mut sk).ok());
assert!(sk.points[la].pos.dist(v(20.0, 20.0)) < 1e-9);
assert_eq!(find_profiles(&sk).len(), 1);
// A different shape rebuilds (the coincidence on the old corner goes).
assert!(sk.update_link(&j, &[LinkGeom::Circle(v(1.0, 1.0), 4.0)]).unwrap());
assert_eq!(sk.link_curves(&j).len(), 1);
assert_eq!(sk.constraints.len(), 0);
// Linked circles keep their radius in the solver.
let c = sk.link_curves(&j)[0];
let rep = solve(&mut sk);
assert!(rep.curve_determined[c], "{rep:?}");
// Breaking the link frees it.
sk.break_link(&j).unwrap();
assert!(sk.links.is_empty());
assert!(sk.curves[c].link.is_none());
let rep = solve(&mut sk);
assert!(!rep.curve_determined[c]);
// Deleting all of a link's curves removes the link.
let j2 = sk.add_link(LinkKind::Project, LinkSource::Origin, &[LinkGeom::Line(v(0.0, 0.0), v(1.0, 0.0))]).unwrap();
let cs = sk.link_curves(&j2);
sk.remove_curves(&cs);
assert!(sk.link(&j2).is_none());
// Nothing to project is an error, not an empty link.
assert!(sk.add_link(LinkKind::Project, LinkSource::Origin, &[LinkGeom::Line(v(0.0, 0.0), v(0.0, 0.0))]).is_err());
// Round trip through JSON.
let j3 = sk.add_link(LinkKind::Intersect, LinkSource::Axis { name: "X".into() }, &[LinkGeom::Point(v(2.0, 2.0))]).unwrap();
let back: Sketch = serde_json::from_str(&serde_json::to_string(&sk).unwrap()).unwrap();
assert_eq!(back, sk);
assert_eq!(back.link(&j3).unwrap().kind, LinkKind::Intersect);
}
/// Many independent constrained rectangles solve quickly (per-component work).
#[test]
fn large_sketch_solves_fast() {
let mut sk = Sketch::new();
for i in 0..150 {
let o = v(i as f64 * 50.0, 0.0);
let l = rect(&mut sk, [o, o + v(40.0, 0.5), o + v(40.5, 30.0), o + v(-0.3, 30.2)]);
use ConstraintKind::*;
sk.add_constraint(Horizontal { l: l[0] }, None).unwrap();
sk.add_constraint(Horizontal { l: l[2] }, None).unwrap();
sk.add_constraint(Vertical { l: l[1] }, None).unwrap();
sk.add_constraint(Vertical { l: l[3] }, None).unwrap();
sk.add_constraint(Length { l: l[0], value: 40.0 }, None).unwrap();
sk.add_constraint(Length { l: l[1], value: 30.0 }, None).unwrap();
}
let t = std::time::Instant::now();
let rep = solve(&mut sk);
let ms = t.elapsed().as_millis();
assert!(rep.ok());
assert_eq!(rep.dof, 300);
assert!(ms < 10_000, "{ms} ms");
eprintln!("large sketch: {ms} ms");
}
#[test]
fn long_connected_chain_solves() {
let mut sk = Sketch::new();
let n = 300;
let mut prev = 0usize;
for i in 0..n {
let p = sk.add_point(v((i + 1) as f64 * 10.0 + 0.3, if i % 2 == 0 { 0.4 } else { -0.2 }), None).unwrap();
let l = sk.add_line_pts(prev, p, None).unwrap();
sk.add_constraint(ConstraintKind::Horizontal { l }, None).unwrap();
sk.add_constraint(ConstraintKind::Length { l, value: 10.0 }, None).unwrap();
prev = p;
}
let t = std::time::Instant::now();
let rep = solve(&mut sk);
let ms = t.elapsed().as_millis();
eprintln!("chain: {ms} ms, iters {}", rep.iterations);
assert!(rep.ok(), "{rep:?}");
assert_eq!(rep.dof, 0);
assert!(ms < 10_000, "{ms} ms");
}
/// Free-form curves stay exact in profiles: a degree-5 spline closed by a line, and a fit spline
/// cut in two by a line through it (each side gets an exact sub-curve ending on the line).
#[test]
fn exact_profiles_for_high_degree_and_cut_free_form_curves() {
use solidcraft_geom::Seg2;
let exact_loop = |p: &Profile| p.region.outer.segs.iter().all(|s| !matches!(s, Seg2::Line { .. }) || s.length() > 1.0);
let closed = |p: &Profile| {
let s = &p.region.outer.segs;
s.iter().zip(s.iter().cycle().skip(1)).all(|(a, b)| a.end().dist(b.start()) < 1e-9)
};
// Degree 5 over six points, closed by a line.
let mut sk = Sketch::default();
let ids: Vec<usize> = [v(0.0, 0.0), v(4.0, 10.0), v(10.0, 12.0), v(16.0, 12.0), v(22.0, 10.0), v(26.0, 0.0)]
.iter()
.map(|p| sk.add_point(*p, None).unwrap())
.collect();
sk.add_curve(CurveKind::Spline { pts: ids.clone(), control: true, degree: 5, periodic: false }, None).unwrap();
sk.add_line_pts(ids[5], ids[0], None).unwrap();
let pr = find_profiles(&sk);
assert_eq!(pr.len(), 1);
assert!(pr[0].region.outer.segs.iter().any(|s| matches!(s, Seg2::Bezier { n: 5, .. })), "{:?}", pr[0].region.outer.segs);
assert!(closed(&pr[0]));
// A fit spline arch over a base line, cut by a vertical line at x = 10.
let mut sk = Sketch::default();
let ids: Vec<usize> = [v(0.0, 0.0), v(6.0, 9.0), v(14.0, 9.0), v(20.0, 0.0)].iter().map(|p| sk.add_point(*p, None).unwrap()).collect();
sk.add_curve(CurveKind::Spline { pts: ids.clone(), control: false, degree: 3, periodic: false }, None).unwrap();
sk.add_line_pts(ids[3], ids[0], None).unwrap();
let (a, b) = (sk.add_point(v(10.0, -5.0), None).unwrap(), sk.add_point(v(10.0, 20.0), None).unwrap());
sk.add_line_pts(a, b, None).unwrap();
let pr = find_profiles(&sk);
assert_eq!(pr.len(), 2, "{pr:?}");
let whole: f64 = pr.iter().map(|p| p.area).sum();
for p in &pr {
assert!(closed(p), "{:?}", p.region.outer.segs);
assert!(exact_loop(p), "polyline pieces left: {:?}", p.region.outer.segs);
// The crossing lies on the line x = 10.
assert!(
p.region
.outer
.segs
.iter()
.any(|s| matches!(s, Seg2::Cubic { .. }) && ((s.start().x - 10.0).abs() < 1e-9 || (s.end().x - 10.0).abs() < 1e-9))
);
}
// Same total area as the uncut arch.
sk.curves.pop();
let uncut: f64 = find_profiles(&sk).iter().map(|p| p.area).sum();
assert!((whole - uncut).abs() < 1e-6 * uncut, "{whole} vs {uncut}");
}
/// A face boundary saved with stations along its straight edges (#1049: booleans used to leave
/// vertices nothing else used) keeps its saved pieces when the face comes back with each
/// straight edge whole: the same region, and features that name the pieces still find them.
#[test]
fn projected_boundary_keeps_its_pieces_when_only_stations_go() {
use crate::{LinkGeom, LinkKind, LinkSource};
let mut sk = Sketch::new();
let v = Vec2::new;
let split = vec![
LinkGeom::Line(v(0.0, 0.0), v(5.0, 0.0)),
LinkGeom::Line(v(5.0, 0.0), v(10.0, 0.0)),
LinkGeom::Line(v(10.0, 0.0), v(10.0, 4.0)),
LinkGeom::Line(v(10.0, 4.0), v(10.0, 10.0)),
LinkGeom::Line(v(10.0, 10.0), v(0.0, 10.0)),
LinkGeom::Line(v(0.0, 10.0), v(0.0, 0.0)),
];
let j = sk.add_link(LinkKind::Project, LinkSource::Origin, &split).unwrap();
let ids: Vec<String> = sk.link_curves(&j).iter().map(|c| sk.curves[*c].id.clone()).collect();
let whole = vec![
LinkGeom::Line(v(0.0, 0.0), v(10.0, 0.0)),
LinkGeom::Line(v(10.0, 0.0), v(10.0, 10.0)),
LinkGeom::Line(v(10.0, 10.0), v(0.0, 10.0)),
LinkGeom::Line(v(0.0, 10.0), v(0.0, 0.0)),
];
assert!(!sk.update_link(&j, &whole).unwrap(), "only the stations went: the pieces stay");
let kept: Vec<String> = sk.link_curves(&j).iter().map(|c| sk.curves[*c].id.clone()).collect();
assert_eq!(ids, kept);
// A boundary that really changed is rebuilt.
let smaller = vec![
LinkGeom::Line(v(0.0, 0.0), v(8.0, 0.0)),
LinkGeom::Line(v(8.0, 0.0), v(8.0, 10.0)),
LinkGeom::Line(v(8.0, 10.0), v(0.0, 10.0)),
LinkGeom::Line(v(0.0, 10.0), v(0.0, 0.0)),
];
assert!(sk.update_link(&j, &smaller).unwrap());
assert_eq!(sk.link_curves(&j).len(), 4);
}