Skip to content

curve: add ParamCurveTangent for pointwise tangents - #556

Open
waywardmonkeys wants to merge 1 commit into
linebender:mainfrom
waywardmonkeys:param-curve-tangent
Open

curve: add ParamCurveTangent for pointwise tangents#556
waywardmonkeys wants to merge 1 commit into
linebender:mainfrom
waywardmonkeys:param-curve-tangent

Conversation

@waywardmonkeys

@waywardmonkeys waywardmonkeys commented Mar 16, 2026

Copy link
Copy Markdown
Contributor

Add ParamCurveTangent as an additive companion to ParamCurveDeriv for code that needs a tangent vector at a parameter value without requiring a derivative curve object.

Place the new trait alongside the other curve traits in param_curve.rs, implement it for Line, ConstPoint, QuadBez, CubicBez, Arc, and PathSeg, and use it in internal call sites that were spelling pointwise tangent queries via deriv().eval(t).to_vec2().

Add tests that check the new tangent API against the existing derivative-curve behavior, cover degenerate normalization cases, and validate Arc's tangent formula and reversed-sweep behavior.

@waywardmonkeys

Copy link
Copy Markdown
Contributor Author

This will make doing tangents for Arc better since doing ParamCurveDeriv for Arc is less good.

@waywardmonkeys

Copy link
Copy Markdown
Contributor Author

This will make doing tangents for Arc better since doing ParamCurveDeriv for Arc is less good.

This now adds support for Arc as well.

@tomcur
tomcur force-pushed the param-curve-tangent branch from 66521c9 to fc417b8 Compare March 16, 2026 17:06

@tomcur tomcur left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aside from a few nits, the implementation looks good. I do wonder whether a different design could also be justified, where we require ParamCurveDeriv to be implemented instead. We could add ParamCurveDeriv::tangent with a default implementation for convenience (and overridable for optimization). That should work, unless there are cases where we know the tangent but can't construct the derivative curve. For Arc, the derivative is another Arc, with effectively the same math as here.

Comment thread kurbo/src/arc.rs
Comment thread kurbo/src/param_curve.rs
Comment thread kurbo/src/param_curve.rs Outdated
Comment thread kurbo/src/simplify.rs Outdated
@waywardmonkeys

Copy link
Copy Markdown
Contributor Author

Aside from a few nits, the implementation looks good. I do wonder whether a different design could also be justified, where we require ParamCurveDeriv to be implemented instead. We could add ParamCurveDeriv::tangent with a default implementation for convenience (and overridable for optimization). That should work, unless there are cases where we know the tangent but can't construct the derivative curve. For Arc, the derivative is another Arc, with effectively the same math as here.

I don't think that the derivative of an Arc is the same type of Arc that we have here, we'd have to have a different type that we only use for this one thing.

That's why we didn't do ParamCurveDeriv for Arc 18 months ago when we were first doing these changes.

Add `ParamCurveTangent` as an additive companion to `ParamCurveDeriv` for code that needs a tangent vector at a parameter value without requiring a derivative curve object.

Place the new trait alongside the other curve traits in `param_curve.rs`, implement it for `Line`, `ConstPoint`, `QuadBez`, `CubicBez`, `Arc`, and `PathSeg`, and use it in internal call sites that were spelling pointwise tangent queries via `deriv().eval(t).to_vec2()`.

Add tests that check the new tangent API against the existing derivative-curve behavior, cover degenerate normalization cases, and validate `Arc`'s tangent formula and reversed-sweep behavior.

@tomcur tomcur left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should land this trait or something like it, but make it stronger so it covers cases ParamCurveDeriv cannot express, or cannot express nicely. In its current form it cannot quite do that.

The main thing that ParamCurveDeriv gives is the ability to use the result as a real object. For example, you can use it to find the second, third, etc., derivatives: if you want to know the second derivative of an Arc, you can let arc_deriv2 = arc.deriv().deriv(), and use it to get at, e.g., the curvature. That's still useful, also for Arc, but I'll open a separate PR for that discussion. It's also useful if you want to compute many tangents, as you only have to calculate the parametrization once.

What ParamCurveTangents could do, which ParamCurveDeriv cannot cleanly, is deal with discontinuities. The existing ParamCurveFit::sample_pt_tangent deals with that, but I think it would be more principled to promote that to a dedicated trait.

I see two ways we could do that. We could make ParamCurveTangent::tangent strong, giving it a parameter saying from which side the point is approached, or we could introduce a second trait here. Something as follows.

trait ParamCurveTangent: ParamCurve {
    fn tangent(&self, t: f64) -> Vec2;
    fn unit_tangent(&self, t: f64) -> Option<Vec2> { ... }
}

trait ParamCurveOneSidedTangent: ParamCurve {
    /// One-sided derivative vector.
    ///
    /// On smooth portions, this is the derivative of the curve
    /// approached from the side of `t` encoded by `sign`. At a
    /// cusp, this is zero.
    fn tangent_from(&self, t: f64, sign: f64) -> Vec2; // or an `enum` instead of `sign: f64`, but `f64` may compose more nicely with other math
}

impl<T: ParamCurveTangent> ParamCurveOneSidedTangent for T {
    fn tangent_from(&self, t: f64, _sign: f64) -> Vec2 {
        self.tangent(t)
    }
}

This is important for some parametric curves, and important for paths, as every knot is a potential discontinuity: often the endpoint tangents are completely unrelated, sometimes the directions do match (G1 continuity), and rarely do the vectors match (C1 continuity). Any piecewise-C1 spline could implement that flavor of ParamCurveOneSidedTangent, but only fully-C1 splines can implement the version in this PR as-is.

There is one more important detail: sometimes a tangent's magnitude is zero, but its direction is well-defined. For example, the Bézier M 6 -3 C -2 3 -2 -3 6 3 has a cusp at t=0.5, whose tangent is (0,0) and tangent direction is (-1, 0) or (1, 0) depending on the side you approach it from. ParamCurveOneSidedTangent could have a unit_tangent_from method that can calculate this.

Then finally, there's a concern orthogonal to all the above. Sometimes when you want to know one tangent of a curve, you want to know more tangents. That argues in favor of storing the parametrization, slightly different to what ParamCurveDeriv does. For example, for a cubic Bézier, if you want to calculate unit_tangent_from, you would want to store the first and second derivatives so evaluation is cheap.

tomcur added a commit to tomcur/kurbo that referenced this pull request Mar 19, 2026
This solves a small part of
linebender#556. Namely: being able to get
at the tangents of an `Arc`.

Similar to how e.g. `CubicBez::deriv` returns a `QuadraticBez` as a
curve or how `Line::deriv` returns `ConstPoint` as a degenerate curve,
even though the derivative actually is a tangent mapping, this
implementation returns `ArcDeriv` as a curve. Numerically this
implementation and the previous implementations are sound, but
`ParamCurveDeriv` itself is muddying semantics a bit. We do document
that already.

`ArcDeriv` is closed under repeated differentation.

Implementing `ParamCurveDeriv` gives the ability to use the result as a
real object. Like the existing implementations, this makes it possible
to find the second, third, etc., derivatives. You can compute the second
derivative of an `Arc` as `let arc_deriv2 = arc.deriv().deriv()`, and
use it to get at, e.g., the arc's curvature.

There's more detail in the discussion of the [PR that triggered
this](linebender#556), thinking of cleaner
traits/types, and perhaps ways to get at tangents for curves that can't
cleanly implement `ParamCurveDeriv`. This current PR intentionally does
the simplest thing to make `Arc` differentiable.
tomcur added a commit to tomcur/kurbo that referenced this pull request Mar 19, 2026
This solves a small part of
linebender#556. Namely: being able to get
at the tangents of an `Arc`.

Similar to how e.g. `CubicBez::deriv` returns a `QuadraticBez` as a
curve or how `Line::deriv` returns `ConstPoint` as a degenerate curve,
even though the derivative actually is a tangent mapping, this
implementation returns `ArcDeriv` as a curve. Numerically this
implementation and the previous implementations are sound, but
`ParamCurveDeriv` itself is muddying semantics a bit. We do document
that already.

`ArcDeriv` is closed under repeated differentation.

Implementing `ParamCurveDeriv` gives the ability to use the result as a
real object. Like the existing implementations, this makes it possible
to find the second, third, etc., derivatives. You can compute the second
derivative of an `Arc` as `let arc_deriv2 = arc.deriv().deriv()`, and
use it to get at, e.g., the arc's curvature.

There's more detail in the discussion of the [PR that triggered
this](linebender#556), thinking of cleaner
traits/types, and perhaps ways to get at tangents for curves that can't
cleanly implement `ParamCurveDeriv`. This current PR intentionally does
the simplest thing to make `Arc` differentiable.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants