Skip to content

Commit 3e3b17f

Browse files
committed
Make solve_itp terminate, and build its schedule without an overflow
1 parent ca27349 commit 3e3b17f

4 files changed

Lines changed: 205 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ This release has an [MSRV][] of 1.85.
1818
## Added
1919
- `serde` and `schemars` support for `Axis`. ([#591][] by [@waywardmonkeys][])
2020

21+
## Fixed
22+
23+
- `common::solve_itp` no longer loops forever when `epsilon` is finer than the spacing of the floats near the zero crossing, which `ParamCurveArclen::inv_arclen` asks for on a long curve. ([#600][] by [@kooshi][])
24+
- `common::solve_itp` no longer overflows when `epsilon` is below `(b - a)` times 2^-63. Its step schedule is built in floating point rather than through a `u64` shift, so the documented lower bound on `epsilon` is gone and `0.0` is accepted. ([#600][] by [@kooshi][])
25+
- `ParamCurveArclen::inv_arclen` now documents that `accuracy` is in arc length, and the resolution limit that bounds it. ([#600][] by [@kooshi][])
26+
2127
## [0.13.1][] (2026-05-13)
2228

2329
This release has an [MSRV][] of 1.85.
@@ -210,6 +216,7 @@ Note: A changelog was not kept for or before this release
210216
[@jrmoulton]: https://github.com/jrmoulton
211217
[@juliapaci]: https://github.com/juliapaci
212218
[@Keavon]: https://github.com/Keavon
219+
[@kooshi]: https://github.com/kooshi
213220
[@LaurenzV]: https://github.com/LaurenzV
214221
[@liferooter]: https://github.com/liferooter
215222
[@nils-mathieu]: https://github.com/nils-mathieu
@@ -317,6 +324,7 @@ Note: A changelog was not kept for or before this release
317324
[#580]: https://github.com/linebender/kurbo/pull/580
318325
[#585]: https://github.com/linebender/kurbo/pull/585
319326
[#591]: https://github.com/linebender/kurbo/pull/591
327+
[#600]: https://github.com/linebender/kurbo/pull/600
320328

321329
[Unreleased]: https://github.com/linebender/kurbo/compare/v0.13.1...HEAD
322330
[0.13.1]: https://github.com/linebender/kurbo/releases/tag/v0.13.1

kurbo/src/common.rs

Lines changed: 148 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ define_float_funcs! {
122122
fn ceil(self) -> Self => ceil/ceilf;
123123
fn cos(self) -> Self => cos/cosf;
124124
fn copysign(self, sign: Self) -> Self => copysign/copysignf;
125+
fn exp2(self) -> Self => exp2/exp2f;
125126
fn floor(self) -> Self => floor/floorf;
126127
fn hypot(self, other: Self) -> Self => hypot/hypotf;
127128
fn ln(self) -> Self => log/logf;
@@ -642,10 +643,8 @@ fn depressed_cubic_dominant(g: f64, h: f64) -> f64 {
642643
/// It is assumed that `ya < 0.0` and `yb > 0.0`, otherwise unexpected
643644
/// results may occur.
644645
///
645-
/// The value of `epsilon` must be larger than 2^-63 times `b - a`,
646-
/// otherwise integer overflow may occur. The `a` and `b` parameters
647-
/// represent the lower and upper bounds of the bracket searched for a
648-
/// solution.
646+
/// The `a` and `b` parameters represent the lower and upper bounds of
647+
/// the bracket searched for a solution.
649648
///
650649
/// The ITP method has tuning parameters. This implementation hardwires
651650
/// k2 to 2, both because it avoids an expensive floating point
@@ -670,6 +669,12 @@ fn depressed_cubic_dominant(g: f64, h: f64) -> f64 {
670669
/// be within `epsilon` of the zero crossing. For more detailed analysis,
671670
/// again see the paper.
672671
///
672+
/// The floats bound that guarantee. A bracket can never be narrower than
673+
/// the spacing between the `f64` values inside it, which near a crossing
674+
/// at `x` is about `f64::EPSILON * x.abs()`. A smaller `epsilon` than
675+
/// that is not an error: the search stops at the tightest bracket there
676+
/// is and returns its midpoint.
677+
///
673678
/// [ITP method]: https://en.wikipedia.org/wiki/ITP_Method
674679
/// [An Enhancement of the Bisection Method Average Performance Preserving Minmax Optimality]: https://dl.acm.org/doi/10.1145/3423597
675680
#[allow(clippy::too_many_arguments)]
@@ -697,6 +702,9 @@ pub fn solve_itp(
697702
///
698703
/// Another difference: it returns the bracket that contains the root,
699704
/// which may be important if the function has a discontinuity.
705+
///
706+
/// The returned bracket is narrower than `2 * epsilon`, or, where the
707+
/// floats there cannot manage that, two adjacent values.
700708
#[allow(clippy::too_many_arguments)]
701709
pub(crate) fn solve_itp_fallible<E>(
702710
mut f: impl FnMut(f64) -> Result<f64, E>,
@@ -708,12 +716,19 @@ pub(crate) fn solve_itp_fallible<E>(
708716
mut ya: f64,
709717
mut yb: f64,
710718
) -> Result<(f64, f64), E> {
711-
let n1_2 = (((b - a) / epsilon).log2().ceil() - 1.0).max(0.0) as usize;
712-
let nmax = n0 + n1_2;
713-
let mut scaled_epsilon = epsilon * (1u64 << nmax) as f64;
719+
// `nmax` comes out of `log2` and is used as `2^nmax`, so it stays a float. As a `usize` it
720+
// overflowed twice: the shift for any `epsilon` below `(b - a) * 2^-63`, which
721+
// `ParamCurveArclen::inv_arclen` asks for on a long curve, and the addition for
722+
// `epsilon == 0.0`, where `inf as usize` saturates to `usize::MAX`.
723+
let n1_2 = (((b - a) / epsilon).log2().ceil() - 1.0).max(0.0);
724+
let nmax = n0 as f64 + n1_2;
725+
let mut scaled_epsilon = epsilon * nmax.exp2();
714726
while b - a > 2.0 * epsilon {
715727
let x1_2 = 0.5 * (a + b);
716-
let r = scaled_epsilon - 0.5 * (b - a);
728+
// A distance in the paper, so never negative there. Unclamped it goes negative once the
729+
// bracket stops halving within the `nmax` budget, and the step below then moves away
730+
// from the root.
731+
let r = (scaled_epsilon - 0.5 * (b - a)).max(0.0);
717732
let xf = (yb * a - ya * b) / (yb - ya);
718733
let sigma = x1_2 - xf;
719734
// This has k2 = 2 hardwired for efficiency.
@@ -728,6 +743,15 @@ pub(crate) fn solve_itp_fallible<E>(
728743
} else {
729744
x1_2 - r.copysign(sigma)
730745
};
746+
// `xitp` can round onto an endpoint while values still sit strictly inside the bracket.
747+
// Bisect rather than give up on a bracket that can still be narrowed.
748+
let xitp = if a < xitp && xitp < b { xitp } else { x1_2 };
749+
// Once nothing is representable strictly between `a` and `b`, even the midpoint rounds
750+
// onto an endpoint and every later pass repeats this one. This is the tightest bracket
751+
// there is.
752+
if !(a < xitp && xitp < b) {
753+
return Ok((a, b));
754+
}
731755
let yitp = f(xitp)?;
732756
if yitp > 0.0 {
733757
b = xitp;
@@ -1097,6 +1121,122 @@ mod tests {
10971121
assert!(f(x).abs() < 6e-12);
10981122
}
10991123

1124+
// The sign changes between two adjacent floats and `epsilon` is far below their spacing, so
1125+
// the bracket can never reach `2 * epsilon`. This ran forever before the fix, which
1126+
// `ParamCurveArclen::inv_arclen` reaches on a long curve. The call budget uses the error
1127+
// channel, so a regression fails instead of hanging.
1128+
#[test]
1129+
fn solve_itp_terminates_when_epsilon_is_below_float_resolution() {
1130+
// A quantity from quadrature never lands on exactly zero, which keeps the
1131+
// `yitp == 0.0` exit shut.
1132+
const Q: f64 = 9.313225746154785e-10;
1133+
const ROOT_BELOW: f64 = 0.1251866955333938;
1134+
1135+
#[derive(Debug)]
1136+
struct BudgetExhausted;
1137+
1138+
let root_above = f64::from_bits(ROOT_BELOW.to_bits() + 1);
1139+
let calls = core::cell::Cell::new(0_u32);
1140+
let f = |x: f64| {
1141+
calls.set(calls.get() + 1);
1142+
match calls.get() {
1143+
0..=200 => Ok(if x <= ROOT_BELOW { -Q } else { Q }),
1144+
_ => Err(BudgetExhausted),
1145+
}
1146+
};
1147+
1148+
let bracket = solve_itp_fallible(f, 0.0, 1.0, 1e-17, 1, 0.2, -Q, Q)
1149+
.expect("the loop must exit on its own, not by exhausting the call budget");
1150+
assert_eq!(bracket, (ROOT_BELOW, root_above));
1151+
}
1152+
1153+
// An `epsilon` below `(b - a) * 2^-63` pushes `nmax` past 63, which the old `1u64 << nmax`
1154+
// wrapped to `nmax % 64` in release. That leaves `scaled_epsilon` a factor of 2^64 too
1155+
// small, so `r` clamps to zero every pass and `xitp` never leaves the midpoint. The first
1156+
// queried point therefore separates the two with no tolerance needed.
1157+
#[test]
1158+
fn solve_itp_uses_its_schedule_when_nmax_exceeds_the_shift_budget() {
1159+
// In `[2^-64, 2^-63)`, so `nmax` is 64 for `n0 = 1` over a unit bracket, one past what
1160+
// the shift could express.
1161+
const EPSILON: f64 = 1e-19;
1162+
// Far enough from the midpoint to survive the `k1 (b - a)^2` truncation. A root near 0.5
1163+
// would put `xt` back on the midpoint for honest reasons.
1164+
const ROOT: f64 = 0.9;
1165+
1166+
#[derive(Debug)]
1167+
struct BudgetExhausted;
1168+
1169+
let first_query = core::cell::Cell::new(None);
1170+
let calls = core::cell::Cell::new(0_u32);
1171+
let f = |x: f64| {
1172+
calls.set(calls.get() + 1);
1173+
if first_query.get().is_none() {
1174+
first_query.set(Some(x));
1175+
}
1176+
// Generous on purpose. The assertions below carry the claim; this only stops a
1177+
// regression from running CI out of time.
1178+
match calls.get() {
1179+
0..=200 => Ok(x * x - ROOT * ROOT),
1180+
_ => Err(BudgetExhausted),
1181+
}
1182+
};
1183+
1184+
let (lo, hi) = solve_itp_fallible(
1185+
f,
1186+
0.0,
1187+
1.0,
1188+
EPSILON,
1189+
1,
1190+
0.2,
1191+
-ROOT * ROOT,
1192+
1.0 - ROOT * ROOT,
1193+
)
1194+
.expect("the loop must exit on its own, not by exhausting the call budget");
1195+
1196+
assert_ne!(
1197+
first_query.get(),
1198+
Some(0.5),
1199+
"the first query was the midpoint, so `r` was zero: `scaled_epsilon` came from a \
1200+
shift that wrapped"
1201+
);
1202+
assert!(
1203+
lo <= ROOT && ROOT <= hi,
1204+
"the returned bracket {lo}..{hi} does not contain the root"
1205+
);
1206+
assert!(hi - lo < 1e-15, "the bracket {lo}..{hi} did not converge");
1207+
}
1208+
1209+
// `epsilon = 0.0` used to overflow the addition above the shift, since `(b - a) / 0.0` is
1210+
// infinite and `inf as usize` saturates to `usize::MAX`. In floating point `nmax` is
1211+
// infinite, `scaled_epsilon` is `NaN`, `r` clamps to zero, and the search bisects. Only a
1212+
// debug build tells the two apart; with checks off the addition wrapped to `n0 - 1`.
1213+
#[test]
1214+
fn solve_itp_accepts_a_zero_epsilon() {
1215+
const ROOT: f64 = 0.9;
1216+
1217+
#[derive(Debug)]
1218+
struct BudgetExhausted;
1219+
1220+
let calls = core::cell::Cell::new(0_u32);
1221+
let f = |x: f64| {
1222+
calls.set(calls.get() + 1);
1223+
match calls.get() {
1224+
0..=200 => Ok(x * x - ROOT * ROOT),
1225+
_ => Err(BudgetExhausted),
1226+
}
1227+
};
1228+
1229+
let (lo, hi) =
1230+
solve_itp_fallible(f, 0.0, 1.0, 0.0, 1, 0.2, -ROOT * ROOT, 1.0 - ROOT * ROOT)
1231+
.expect("the loop must exit on its own, not by exhausting the call budget");
1232+
1233+
assert!(
1234+
lo <= ROOT && ROOT <= hi,
1235+
"the returned bracket {lo}..{hi} does not contain the root"
1236+
);
1237+
assert!(hi - lo < 1e-15, "the bracket {lo}..{hi} did not converge");
1238+
}
1239+
11001240
#[test]
11011241
fn test_inv_arclen() {
11021242
use crate::{ParamCurve, ParamCurveArclen};

kurbo/src/cubicbez.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -921,6 +921,45 @@ mod tests {
921921
}
922922
}
923923

924+
// `inv_arclen` derives its tolerance as `accuracy / total_arclen`, so a long enough curve
925+
// asks for a bracket finer than the floats in `[0, 1]` can express. Arc lengths closer
926+
// together than about `total * 2^-53` then share a parameter, which is documented. What must
927+
// not happen is that the call goes unanswered. The two tests above use curves about 1 and
928+
// 100 long, so neither gets here.
929+
#[test]
930+
fn cubicbez_inv_arclen_below_float_resolution() {
931+
// About 1e10 long, at an accuracy of 1e-9: the tolerance this derives is 9.9e-20, and one
932+
// f64 step just below t = 1 is 1.1e-16.
933+
let s = 1.0e10;
934+
let c = CubicBez::new(
935+
(0.0, 0.0),
936+
(0.5 * s, 0.0),
937+
(0.5 * s, 0.1 * s),
938+
(1.0 * s, 0.1 * s),
939+
);
940+
let accuracy = 1.0e-9;
941+
let total_arclen = c.arclen(accuracy);
942+
// The finest arc length a parameter can resolve, from the doc comment on `inv_arclen`.
943+
// The slack covers quadrature error here and in the solve; the worst of these eight
944+
// positions leaves 1.71 steps.
945+
let finest = total_arclen * (-53.0f64).exp2();
946+
947+
for percent in [1, 10, 25, 37, 50, 75, 90, 99] {
948+
let arc = total_arclen * (percent as f64) * 0.01;
949+
let t = c.inv_arclen(arc, accuracy);
950+
assert!(
951+
0.0 < t && t < 1.0,
952+
"at {percent}% of the arc length, inv_arclen returned {t}, which is not inside the curve"
953+
);
954+
let residual = (c.subsegment(0.0..t).arclen(accuracy) - arc).abs();
955+
assert!(
956+
residual <= 4.0 * finest,
957+
"at {percent}% of the arc length, inv_arclen left {residual:e} of arc length, \
958+
more than the {finest:e} that one f64 step of the parameter is worth"
959+
);
960+
}
961+
}
962+
924963
#[test]
925964
#[allow(clippy::float_cmp)]
926965
fn cubicbez_signed_area_linear() {

kurbo/src/param_curve.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,16 @@ pub trait ParamCurveArclen: ParamCurve {
8989

9090
/// Solve for the parameter that has the given arc length from the start.
9191
///
92+
/// `accuracy` is in the same units as `arclen`: the arc length from
93+
/// the start of the curve to the returned parameter is within
94+
/// `accuracy` of `arclen`, where that is achievable.
95+
///
96+
/// It is not always achievable. Adjacent `f64` parameters near 1
97+
/// differ by 2^-53, so on a curve of arc length `L`, arc lengths
98+
/// closer together than about `L * 2^-53` share a parameter. That is
99+
/// around 1e-6 on a curve 1e10 long. Asking for less is not an error
100+
/// and is not reported.
101+
///
92102
/// This implementation uses the IPT method, as provided by
93103
/// [`common::solve_itp`]. This is as robust as bisection but
94104
/// typically converges faster. In addition, the method takes

0 commit comments

Comments
 (0)