You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This is another one found by Claude while processing a bunch of Font Awesome SVGs
simplify_bezpath samples zero tangents at polyline run endpoints, fitting curves that leave or arrive in arbitrary axis-aligned directions
SimplifyBezPath::sample_pt_tangent returns the zero vector at the endpoints of
every run built from line segments. fit_to_cubic then computes its tangent
angles via Vec2::ZERO.atan2() == 0.0, which silently substitutes the world
+x axis for the true tangent. Depending on how that garbage constraint
interacts with the error test, the fit at a run boundary is accidentally fine,
quietly degenerate (a zero-length control arm), or visibly wrong — a cubic
whose end tangent points backwards, so the curve runs past its endpoint, turns
around, and comes back (a hairpin).
The simplify module docs already note the underlying limitation:
A current limitation (hoped to be addressed in the future) is that non-regular
cubic segments may have tangents computed incorrectly. This can easily happen,
for example when setting a control point equal to an endpoint.
What this issue adds: that limitation is not an edge case — it fires at both
endpoints of every run of every polyline input, because Line::to_cubic()
produces exactly such a non-regular cubic (p1 == p0, p2 == p3, so the
derivative 6t(1−t)·d vanishes at t = 0 and t = 1), and polylines are the
primary input simplify_bezpath exists to serve.
Minimal reproduction
A quarter circle. No magic coordinates, undeniably smooth input.
use kurbo::simplify::{simplify_bezpath,SimplifyOptions,SimplifyOptLevel};use kurbo::{BezPath,PathSeg,Point};fnmain(){// Quarter circle of radius 100, sampled at 32 chords: from (100, 0) to// (0, 100). The polyline arrives at its last point travelling (-1, 0).letmut path = BezPath::new();
path.move_to(Point::new(100.0,0.0));for i in1..=32{let th = (i asf64)* std::f64::consts::FRAC_PI_2 / 32.0;
path.line_to(Point::new(100.0* th.cos(),100.0* th.sin()));}let options = SimplifyOptions::default().angle_thresh(0.25).opt_level(SimplifyOptLevel::Optimize);let fitted = simplify_bezpath(path.iter(),1.0,&options);println!("{}", fitted.to_svg());letPathSeg::Cubic(c) = fitted.segments().last().unwrap()else{panic!()};let arrival = c.p3 - c.p2;assert!(
arrival.x < 0.0,"end tangent reversed: fit arrives travelling ({:+.3}, {:+.3}), \ the polyline arrives travelling (-1, 0)",
arrival.x, arrival.y,);}
Observed (kurbo 0.13.1, also 0.13.0 and current main-derived branches):
Three symptoms, all at run boundaries, all axis-aligned — the fingerprint of the
substituted +x tangent:
the first cubic's start arm is (0, -2.5e-32) — degenerate, axis-pinned;
the last cubic's end arm is p3 - p2 = (+12.708, 0) — exactly
horizontal and pointing +x, when the polyline arrives travelling (-1, 0).
The curve overshoots its endpoint to x = -3.07, turns around, and comes
back;
the fit needs 3 cubics and still wobbles.
Expected: a quarter circle is the textbook single-cubic fit (arm length (4/3)·tan(π/8)·R ≈ 0.552·R). With the one-hunk fix below, the same input fits
as exactly that — one cubic, correct tangents at both ends:
M100,0 C98.5996,57.0444 57.0444,98.5996 0,100
Mechanism
SimplifyBezPath::new converts each queued segment with seg.to_cubic().
For a Line, that is the non-regular parametrization (p0, p0, p1, p1),
whose derivative 6t(1−t)·d is exactly zero at t = 0 and t = 1.
SimplifyBezPath::sample_pt_tangent evaluates that derivative directly, so
any parameter landing exactly on a segment boundary yields Vec2::ZERO.
Run endpoints (t = 0.0 and t = 1.0 of every run) always do; interior
subdivision points chosen by fit_to_bezpath_opt's ITP solver do whenever
they converge onto a source vertex.
fit_to_cubic computes th0 = mod_2pi(start.tangent.atan2() - th) (and th1 likewise). Vec2::ZERO.atan2() == 0.0, so the tangent constraint
becomes "the world +x axis" — unrelated to the curve. Every candidate cubic_fit builds then has its control arm pinned to that garbage direction.
Note this affects fit_to_bezpath (Subdivide) too — same tangent samples —
but subdivision usually masks it; the optimizing path exposes it because a
single cubic spans a whole run boundary-to-boundary.
Real-world impact
Found in production through a polygon-offset pipeline (clipper2 polygon output
→ simplify_bezpath refit) for icon rendering: offsetting the ban icon
(Font Awesome) produces a ring whose fit carries a 62.7-unit backward arm at
the sub-path seam — a 16×2-unit hairpin bitten out of the ring's silhouette,
directly visible in the rendered icon. Sweeping a 9,608-icon corpus, 3,116
icons carry a backward endpoint arm over 4 units in fitted offset output; 91
exceed 64 units. A reduced 24-point instance is attached below.
Everything about that failing cubic that looked inexplicable is explained by
the substituted axis: its end arm is exactly horizontal ((78.2, 0.0)) in a
place where the true tangent has slope ≈ 0.02.
24-point reduction of the production case (open polyline)
Fit with angle_thresh(0.25), Optimize, accuracy 2.0: the last cubic's end
arm is (+35.8, 0) against a true arrival direction of (-7.55, -2.22) — the
curve overshoots its endpoint by 8.4 units and doubles back.
Independent. Verified against a branch carrying the #604 fixes (corner test, D_MAX arm rejection, relative-coordinate moment integrals): the production
ring's hairpin cubic is reproduced bit-identically, so accurate moments do
not touch this class. The two issues share only the one-sided error metric as
an accomplice.
Suggested fix (verified)
Make sample_pt_tangent robust to non-regular cubics — the same policy PathSeg::tangents() already implements privately. Minimal form:
// SimplifyBezPath::sample_pt_tangentletmut tangent = c.deriv().eval(t0).to_vec2();if tangent.hypot2() == 0.0{// Non-regular cubic (e.g. a Line via to_cubic, whose derivative// vanishes at its endpoints): fall back to the chord.
tangent = c.p3 - c.p0;}
The chord is the exact tangent for the line-segment case, which is the only way
these degenerate cubics arise here. With this hunk:
the quarter circle fits as the single textbook cubic shown above;
the production ring's backward arm goes from 62.7 to 0.0, and its fit
shrinks from 8 elements to 4;
kurbo's own test suite passes unchanged (191 + 55, debug profile).
Alternatives considered:
Regularize Line::to_cubic() to (p0, p0 + d/3, p0 + 2d/3, p1) — fixes
the derivative everywhere and gives exact linear parametrization, but to_cubic is public API with other consumers, so the blast radius is larger.
Handle zero tangents explicitly in fit_to_cubic (treat as
unconstrained rather than atan2(0,0)) — more general, but the constraint
machinery has no "unconstrained" representation today.
A general robust-tangent walk (next distinct control point, as in PathSeg::tangents()) rather than the chord — equivalent here, and the right
shape if SimplifyBezPath ever queues non-regular curved segments.
This is another one found by Claude while processing a bunch of Font Awesome SVGs
simplify_bezpathsamples zero tangents at polyline run endpoints, fitting curves that leave or arrive in arbitrary axis-aligned directionsSimplifyBezPath::sample_pt_tangentreturns the zero vector at the endpoints ofevery run built from line segments.
fit_to_cubicthen computes its tangentangles via
Vec2::ZERO.atan2() == 0.0, which silently substitutes the world+x axis for the true tangent. Depending on how that garbage constraint
interacts with the error test, the fit at a run boundary is accidentally fine,
quietly degenerate (a zero-length control arm), or visibly wrong — a cubic
whose end tangent points backwards, so the curve runs past its endpoint, turns
around, and comes back (a hairpin).
The
simplifymodule docs already note the underlying limitation:What this issue adds: that limitation is not an edge case — it fires at both
endpoints of every run of every polyline input, because
Line::to_cubic()produces exactly such a non-regular cubic (
p1 == p0,p2 == p3, so thederivative
6t(1−t)·dvanishes att = 0andt = 1), and polylines are theprimary input
simplify_bezpathexists to serve.Minimal reproduction
A quarter circle. No magic coordinates, undeniably smooth input.
Observed (kurbo 0.13.1, also 0.13.0 and current
main-derived branches):Three symptoms, all at run boundaries, all axis-aligned — the fingerprint of the
substituted +x tangent:
(0, -2.5e-32)— degenerate, axis-pinned;p3 - p2 = (+12.708, 0)— exactlyhorizontal and pointing +x, when the polyline arrives travelling
(-1, 0).The curve overshoots its endpoint to
x = -3.07, turns around, and comesback;
Expected: a quarter circle is the textbook single-cubic fit (arm length
(4/3)·tan(π/8)·R ≈ 0.552·R). With the one-hunk fix below, the same input fitsas exactly that — one cubic, correct tangents at both ends:
Mechanism
SimplifyBezPath::newconverts each queued segment withseg.to_cubic().For a
Line, that is the non-regular parametrization(p0, p0, p1, p1),whose derivative
6t(1−t)·dis exactly zero att = 0andt = 1.SimplifyBezPath::sample_pt_tangentevaluates that derivative directly, soany parameter landing exactly on a segment boundary yields
Vec2::ZERO.Run endpoints (
t = 0.0andt = 1.0of every run) always do; interiorsubdivision points chosen by
fit_to_bezpath_opt's ITP solver do wheneverthey converge onto a source vertex.
fit_to_cubiccomputesth0 = mod_2pi(start.tangent.atan2() - th)(andth1likewise).Vec2::ZERO.atan2() == 0.0, so the tangent constraintbecomes "the world +x axis" — unrelated to the curve. Every candidate
cubic_fitbuilds then has its control arm pinned to that garbage direction.CurveDistmeasuressource-samples → candidate distance only, so a candidate that covers the
source and adds an excursion past the endpoint scores as an excellent fit
(this is the same one-sidedness noted in
fit_to_bezpath_optemits a control point 19 000 units outside the input, and its own error check reports the fit as exact #604).Note this affects
fit_to_bezpath(Subdivide) too — same tangent samples —but subdivision usually masks it; the optimizing path exposes it because a
single cubic spans a whole run boundary-to-boundary.
Real-world impact
Found in production through a polygon-offset pipeline (clipper2 polygon output
→
simplify_bezpathrefit) for icon rendering: offsetting thebanicon(Font Awesome) produces a ring whose fit carries a 62.7-unit backward arm at
the sub-path seam — a 16×2-unit hairpin bitten out of the ring's silhouette,
directly visible in the rendered icon. Sweeping a 9,608-icon corpus, 3,116
icons carry a backward endpoint arm over 4 units in fitted offset output; 91
exceed 64 units. A reduced 24-point instance is attached below.
Everything about that failing cubic that looked inexplicable is explained by
the substituted axis: its end arm is exactly horizontal (
(78.2, 0.0)) in aplace where the true tangent has slope ≈ 0.02.
24-point reduction of the production case (open polyline)
Fit with
angle_thresh(0.25),Optimize, accuracy 2.0: the last cubic's endarm is
(+35.8, 0)against a true arrival direction of(-7.55, -2.22)— thecurve overshoots its endpoint by 8.4 units and doubles back.
Relationship to #604
Independent. Verified against a branch carrying the #604 fixes (corner test,
D_MAXarm rejection, relative-coordinate moment integrals): the productionring's hairpin cubic is reproduced bit-identically, so accurate moments do
not touch this class. The two issues share only the one-sided error metric as
an accomplice.
Suggested fix (verified)
Make
sample_pt_tangentrobust to non-regular cubics — the same policyPathSeg::tangents()already implements privately. Minimal form:The chord is the exact tangent for the line-segment case, which is the only way
these degenerate cubics arise here. With this hunk:
shrinks from 8 elements to 4;
Alternatives considered:
Line::to_cubic()to(p0, p0 + d/3, p0 + 2d/3, p1)— fixesthe derivative everywhere and gives exact linear parametrization, but
to_cubicis public API with other consumers, so the blast radius is larger.fit_to_cubic(treat asunconstrained rather than
atan2(0,0)) — more general, but the constraintmachinery has no "unconstrained" representation today.
PathSeg::tangents()) rather than the chord — equivalent here, and the rightshape if
SimplifyBezPathever queues non-regular curved segments.