Exact profiles for splines of any degree (a degree-4…7 Bézier segment kind, Seg2::Bezier, extruded as B-spline edges) and for free-form curves cut by crossings (exact sub-curves whose ends are moved to the true crossing, found by Newton; polylines only where that fails)

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-08 09:42:34 +00:00
1 parent f79b5c1045
commit b9bc75670c
11 files changed
+389 -34

No files matched your search

+2 -12
View File
@@ -904,20 +904,10 @@ impl Sketch {
let p: Vec<Vec2> = pts.iter().map(pt).collect::<Option<_>>()?;
Some(crate::curves::fit_beziers(&p).into_iter().map(|[p0, p1, p2, p3]| Seg2::Cubic { p0, p1, p2, p3 }).collect())
}
CurveKind::Spline { pts, control: true, degree } if *degree <= 3 => {
CurveKind::Spline { pts, control: true, degree } if (*degree as usize) <= solvecraft_geom::MAX_BEZIER_DEGREE => {
let p: Vec<Vec2> = pts.iter().map(pt).collect::<Option<_>>()?;
let k = (*degree as usize).min(p.len().saturating_sub(1)).max(1);
Some(
crate::curves::bspline_beziers(&p, k)
.into_iter()
.filter_map(|c| match c[..] {
[a, b] => Some(Seg2::Line { a, b }),
[a, x, b] => Some(Seg2::Conic { a, apex: x, b, w: 1.0 }),
[p0, p1, p2, p3] => Some(Seg2::Cubic { p0, p1, p2, p3 }),
_ => None,
})
.collect(),
)
Some(crate::curves::bspline_beziers(&p, k).iter().filter_map(|c| solvecraft_geom::bezier(c)).collect())
}
_ => None,
}
+199 -12
View File
@@ -72,7 +72,10 @@ fn end_param(sh: &Shape, p: Vec2, tol: f64) -> Option<f64> {
}
}
/// Put exact free-form segments back where a loop runs over all the pieces of one, in order.
/// Put exact free-form segments back. A loop that runs over all the pieces of one, in order,
/// gets the whole segment; one that runs over part of it (cut by a crossing) gets the exact
/// sub-segment, its ends moved to the true crossing with the neighbouring segment. Where that
/// crossing cannot be found the loop keeps the polyline pieces.
fn restore_exact(segs: Vec<Seg2>, tags: &[Option<(Piece, bool)>], exact: &std::collections::HashMap<usize, Vec<Seg2>>) -> Vec<Seg2> {
let n = segs.len();
if n == 0 || tags.len() != n || tags.iter().all(Option::is_none) {
@@ -81,7 +84,7 @@ fn restore_exact(segs: Vec<Seg2>, tags: &[Option<(Piece, bool)>], exact: &std::c
let key = |i: usize| tags.get(i % n).copied().flatten().map(|((ci, k, _, _), fwd)| (ci, k, fwd));
// Start where a run begins so no run wraps around.
let r = (0..n).find(|i| key(*i) != key(*i + n - 1)).unwrap_or(0);
let mut out = Vec::with_capacity(n);
let mut runs: Vec<Run> = Vec::new();
let mut i = 0;
while i < n {
let k0 = key(r + i);
@@ -90,21 +93,205 @@ fn restore_exact(segs: Vec<Seg2>, tags: &[Option<(Piece, bool)>], exact: &std::c
j += 1;
}
let run: Vec<usize> = (i..j).map(|x| (r + x) % n).collect();
let whole = match (k0, tags.get(run[0]).copied().flatten()) {
(Some((ci, k, fwd)), Some(((_, _, _, cnt), _))) if run.len() == cnt => {
let plain: Vec<Seg2> = run.iter().filter_map(|x| segs.get(*x).copied()).collect();
let full = k0.and_then(|(ci, k, fwd)| exact.get(&ci).and_then(|e| e.get(k)).map(|s| if fwd { *s } else { s.reversed() }));
let whole = match (full, tags.get(run[0]).copied().flatten()) {
(Some(_), Some(((_, _, _, cnt), fwd))) if run.len() == cnt => {
let order: Vec<usize> = run.iter().filter_map(|x| tags.get(*x).copied().flatten().map(|((_, _, p, _), _)| p)).collect();
let want: Vec<usize> = if fwd { (0..cnt).collect() } else { (0..cnt).rev().collect() };
if order == want { exact.get(&ci).and_then(|e| e.get(k)).map(|s| if fwd { *s } else { s.reversed() }) } else { None }
order == want
}
_ => None,
_ => false,
};
match whole {
Some(s) => out.push(s),
None => out.extend(run.iter().filter_map(|x| segs.get(*x).copied())),
}
runs.push(match full {
Some(seg) if whole => Run::Exact { seg, t0: 0.0, t1: 1.0, fixed: (true, true), plain },
Some(seg) => {
let (a, b) = (plain.first().map(Seg2::start).unwrap_or_default(), plain.last().map(Seg2::end).unwrap_or_default());
let (t0, t1) = (seg.closest_param(a), seg.closest_param(b));
// Ends that are the segment's own ends need no search.
let snap = |t: f64, p: Vec2, at: f64| (t - at).abs() < 1e-6 && seg.point_at(at).dist(p) < 1e-6;
let fixed = (snap(t0, a, 0.0), snap(t1, b, 1.0));
let (t0, t1) = (if fixed.0 { 0.0 } else { t0 }, if fixed.1 { 1.0 } else { t1 });
if t1 - t0 > 1e-9 { Run::Exact { seg, t0, t1, fixed, plain } } else { Run::Plain(plain) }
}
None => Run::Plain(plain),
});
i = j;
}
out
// Junctions next to a cut exact run: find the true crossing.
let m = runs.len();
for a in 0..m {
let b = (a + 1) % m;
let need = match (runs.get(a), runs.get(b)) {
(Some(Run::Exact { fixed, .. }), _) if !fixed.1 => true,
(_, Some(Run::Exact { fixed, .. })) if !fixed.0 => true,
_ => false,
};
if !need {
continue;
}
let (Some(ra), Some(rb)) = (runs.get(a), runs.get(b)) else { continue };
let p0 = ra.end();
let crossing = refine_crossing(ra, rb, p0);
match crossing {
Some((p, ta, tb)) if p.dist(p0) < 1e-2 => {
if let Some(x) = runs.get_mut(a) {
x.set_end(p, ta);
}
if let Some(x) = runs.get_mut(b) {
x.set_start(p, tb);
}
}
_ => {
for k in [a, b] {
if let Some(x) = runs.get_mut(k) {
x.give_up();
}
}
}
}
}
runs.into_iter().flat_map(Run::into_segs).collect()
}
/// A stretch of a profile loop: plain segments, or (part of) an exact free-form segment.
enum Run {
Plain(Vec<Seg2>),
/// `seg` (in loop direction) between parameters `t0` and `t1`; `fixed` ends are exact;
/// `plain` is the polyline fallback.
Exact {
seg: Seg2,
t0: f64,
t1: f64,
fixed: (bool, bool),
plain: Vec<Seg2>,
},
}
impl Run {
fn end(&self) -> Vec2 {
match self {
Run::Plain(v) => v.last().map(Seg2::end).unwrap_or_default(),
Run::Exact { seg, t1, .. } => seg.point_at(*t1),
}
}
/// The segment at the run's end and the parameter of the end on it.
fn end_geom(&self) -> Option<(Seg2, f64)> {
match self {
Run::Plain(v) => v.last().map(|s| (*s, 1.0)),
Run::Exact { seg, t1, .. } => Some((*seg, *t1)),
}
}
/// The segment at the run's start and the parameter of the start on it.
fn start_geom(&self) -> Option<(Seg2, f64)> {
match self {
Run::Plain(v) => v.first().map(|s| (*s, 0.0)),
Run::Exact { seg, t0, .. } => Some((*seg, *t0)),
}
}
fn set_end(&mut self, p: Vec2, t: f64) {
match self {
Run::Plain(v) => {
if let Some(s) = v.last_mut() {
*s = with_end(*s, p);
}
}
Run::Exact { t1, .. } => *t1 = t,
}
}
fn set_start(&mut self, p: Vec2, t: f64) {
match self {
Run::Plain(v) => {
if let Some(s) = v.first_mut() {
*s = with_end(s.reversed(), p).reversed();
}
}
Run::Exact { t0, .. } => *t0 = t,
}
}
fn give_up(&mut self) {
if let Run::Exact { fixed: (false, _) | (_, false), plain, .. } = self {
*self = Run::Plain(std::mem::take(plain));
}
}
fn into_segs(self) -> Vec<Seg2> {
match self {
Run::Plain(v) => v,
Run::Exact { seg, t0, t1, .. } => {
if t0 <= 1e-12 && t1 >= 1.0 - 1e-12 {
return vec![seg];
}
let s = if t1 < 1.0 - 1e-12 { seg.split_at(t1).0 } else { seg };
if t0 <= 1e-12 {
return vec![s];
}
// Conic halves are reparametrised: find t0's point again on the left part.
let u = local_param(&s, seg.point_at(t0), if t1 > 1e-12 { t0 / t1 } else { 0.0 });
vec![s.split_at(u.clamp(1e-9, 1.0 - 1e-9)).1]
}
}
}
}
/// The segment with its end moved to `p` (lines; arcs keep their circle).
fn with_end(s: Seg2, p: Vec2) -> Seg2 {
match s {
Seg2::Line { a, .. } => Seg2::Line { a, b: p },
Seg2::Arc { center, radius, start, sweep } => {
let target = (p - center).angle();
let end = start + sweep;
// The same end angle, wound the same way.
let delta = (target - end + std::f64::consts::PI).rem_euclid(TAU) - std::f64::consts::PI;
Seg2::Arc { center, radius, start, sweep: sweep + delta }
}
other => other,
}
}
/// Where the end of run `a` meets the start of run `b`, near `p`: the point and the parameters
/// on the two runs' segments (Newton on A(ta) = B(tb); lines and arcs extend past their ends).
fn refine_crossing(a: &Run, b: &Run, p: Vec2) -> Option<(Vec2, f64, f64)> {
let (sa, mut ta) = a.end_geom()?;
let (sb, mut tb) = b.start_geom()?;
// Start from the parameters nearest the polyline crossing.
ta = local_param(&sa, p, ta);
tb = local_param(&sb, p, tb);
for _ in 0..50 {
let f = sa.point_at(ta) - sb.point_at(tb);
if f.len() < 1e-11 {
let pt = match (a, b) {
(Run::Exact { .. }, _) | (Run::Plain(_), Run::Plain(_)) => sa.point_at(ta),
(_, Run::Exact { .. }) => sb.point_at(tb),
};
return (ta.is_finite() && tb.is_finite()).then_some((pt, ta, tb));
}
let (da, db) = (sa.derivative(ta), -sb.derivative(tb));
let det = da.cross(db);
if det.abs() < 1e-14 * da.len().max(1e-300) * db.len().max(1e-300) {
return None;
}
// Solve [da db] (dta, dtb)ᵀ = −f.
ta -= f.cross(db) / det;
tb -= da.cross(f) / det;
}
None
}
/// Parameter of the point of `s` nearest `p`, by Newton from `t`.
fn local_param(s: &Seg2, p: Vec2, mut t: f64) -> f64 {
for _ in 0..30 {
let (q, d) = (s.point_at(t), s.derivative(t));
let dd = d.dot(d);
if dd < 1e-300 {
break;
}
let step = (p - q).dot(d) / dd;
t += step;
if step.abs() < 1e-15 {
break;
}
}
t
}
/// A uniform grid over a set of boxes (about one box per cell), for neighbour queries.
@@ -161,7 +348,7 @@ fn curvature(s: &Seg2) -> f64 {
match *s {
Seg2::Line { .. } => 0.0,
Seg2::Arc { radius, sweep, .. } => sweep.signum() / radius.max(1e-12),
Seg2::Cubic { .. } | Seg2::Conic { .. } => {
Seg2::Cubic { .. } | Seg2::Conic { .. } | Seg2::Bezier { .. } => {
let (d1, h) = (s.derivative(0.0), 1e-5);
let d2 = (s.derivative(h) - d1) / h;
d1.cross(d2) / d1.len().powi(3).max(1e-300)
+50
View File
@@ -407,3 +407,53 @@ fn long_connected_chain_solves() {
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 solvecraft_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 }, 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 }, 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}");
}