Skip to content

simplify_bezpath samples zero tangents at polyline run endpoints, fitting curves that leave or arrive in arbitrary axis-aligned directions #605

Description

@mlwilkerson

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};

fn main() {
    // 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).
    let mut path = BezPath::new();
    path.move_to(Point::new(100.0, 0.0));
    for i in 1..=32 {
        let th = (i as f64) * 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());

    let PathSeg::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):

M100,0 C100,-0.0000…25e-32 99.9621,1.5422 99.8795,4.9068
       C99.6753,13.2271 96.1980,66.7095 38.2886,92.3790
       C16.8002,101.9041 -12.7083,100 0.0000…71e-15,100

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

  1. 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.
  2. 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.
  3. 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.
  4. The error metric cannot veto the result: CurveDist measures
    source-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_opt emits 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_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)
const PTS: &[(f64, f64)] = &[
    (452.22, 325.29),
    (463.39, 271.94),
    (463.84000000000003, 248.03),
    (463.35, 239.48000000000002),
    (462.65000000000003, 232.19),
    (461.54, 223.96),
    (460.21000000000004, 216.3),
    (458.56, 208.59),
    (456.58, 200.8),
    (454.36, 193.25),
    (451.76, 185.55),
    (449.03000000000003, 178.39000000000001),
    (445.88, 171.01),
    (442.55, 163.95000000000002),
    (438.85, 156.81),
    (405.97, 111.92),
    (400.07, 106.02),
    (394.37, 100.73),
    (348.04, 69.44),
    (340.98, 66.11),
    (333.6, 62.96),
    (326.44, 60.230000000000004),
    (318.74, 57.63),
    (311.19, 55.410000000000004),
];

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.

Relationship to #604

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_tangent
let mut 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions