linebender / kurbo

A Rust library for manipulating curves
Apache License 2.0
704 stars 68 forks source link

Proposal: Intersection trait #274

Open derekdreery opened 1 year ago

derekdreery commented 1 year ago

Currently there is some support for intersection within kurbo, but it is spread around. I propose a new trait called ParamCurveIntersect defined like

pub trait ParamCurveIntersect<T, const N: usize> {
    fn intersections(&self, other: &T) -> ArrayVec<(t, t), N>;
}

Implementers are encouraged to implement the trait in both directions (i.e. if they impl ParamCurveIntersect<T, _> for U then they should also impl ParamCurveIntersect<U, _> for T. A blanket impl is not possible because of the case impl ParamCurveIntersect<T, _> for T, and might not be desirable anyway.

The trait signature assumes that the number of intersections is bounded and small. We could instead fix N (to e.g. 9, the maximum number of cubic Bezier intersections), but this could lead to wasted space.

Existing work

There is some existing work for intersections.

raphlinus commented 1 year ago

I'm not sold on this, for a variety of reasons, but I could be swayed if there were really clearly defined use cases. To me, the primary use case is implementing boolean path ops (we should have a tracking issue for that), and I don't believe that this signature is sufficient for that.

Biggest problem: just listing intersections is not adequate for robust path operations. In general, you need to know the orientation (y = 0 and y = x^n intersect at the origin, but the sign of the orientation only flips for odd n), and there's also the rather serious problem of detecting the case when two curve segments are nearly (within epsilon) on top of each other. My understanding is that algorithms based on adaptive subdivision behave very poorly in the latter case; they tend to keep subdividing, and will likely report some arbitrary set of intersections. So I firmly believe that a key step toward path ops is designing an intersection method with the appropriate signature. In particular, it should report orientation explicitly when it is possible to determine robustly, and also that relevant subranges are within epsilon Fréchet distance. My current thinking is geared strongly toward sweep line algorithms so that one cartesian coordinate is preferred over the other, and ranges are reported in terms of that coordinate, but algorithms in the literature seem to lean more toward reporting based on the t parameter. (To a certain extent, these aren't hugely different because a sweep line algorithm will by necessity split curves to be monotonic wrt the sweep direction, but it affects details of the method signature)

Additional problem: the proposed trait represents intersection of the curve type with another curve of the same type, but that's not the only case. Line/curve intersections are also important for path ops, and tend to be quite a bit easier to implement (it's just a polynomial solve of the same order as the Bézier). Also, if we're considering a hypothetical SVG path representation including arcs (#265), then we'd need to solve at least the arc/cubic case as well. Thinking about this now, it may be the best approach to both these problems is to have an enum type analogous to PathSeg, and the n^2 cases would be dealt with by match on self and other.

My sense right now. The next step in this journey should be actually implementing robust path ops for BezPath, which is a hard enough problem, and doesn't require any new generic traits. When that's done, we should take a look at the intersection primitives, addressing two separate but related questions:

raphlinus commented 1 year ago

I'm realizing that in the above writeup, I'm mixing up two distinct concepts. Let me separate them now.

One concept is a trait method that represents the results of curve intersection, taking as input a pair of curve segements, without specifying in any way how that's computed. To handle the n^2 matrix of possible curve types, this trait would be implemented on an enum. It would be broadly similar to what's proposed, though I still argue that just returning t values of intersection points is inadequate for robust path ops; an extension would allow for higher-order intersections and nearly-coincident ranges.

The other concept is a trait method (or set of trait methods) to measure a single curve for the purpose of computing intersections with other curves. My work-in-progress suggests that a core method is to compute a bounding parabola for a given range. Bounding parabolas can than be used to derive orientation (if they don't intersect at all), determine that two curves are within a certain Hausdorff distance (which is probably equivalent enough in practice to Fréchet, especially if this is only used for monotonic curve segments, or drive subdivision: any intersections will by definition be contained in the intersection of the two bounding parabolas.

That second approach is potentially an appealing solution to the general n^2 problem, for example computing an intersection between an arc and a cubic Bézier. You'd still want to handle the other special cases, for example there's now code for analytical computation of the intersection between two quadratics, using a quartic solver.

My conclusion is the same; we need to think more carefully about use cases first, and it would also be very helpful to have a complete implementation of path ops on Bézier paths before thinking too much about generalizing it.

derekdreery commented 1 year ago

Thanks for the detailed response. I want to respond to one small point, and then talk more generally.

Additional problem: the proposed trait represents intersection of the curve type with another curve of the same type, but that's not the only case.

The signature actually allows Self and T to be different types. They can be the same (and it often makes sense to implement the case T = Self), but they don't have to be. For example you could impl ParamCurveIntersect<QuadBez, 2> for Line.


More generally, I think I have a slightly different use case. For stroking paths, then it is perhaps true that the only use of computing intersections is in the 'unioning' the derived fill. But I'm also interested in path offsets as a tool for designers. For this use case, my current plan is to compute the offset of each segment, join them (depending on some linejoin-like parameter), find intersections and split the curve at intersection points, then cull any geometry whose nearest point is nearer than the offset distance. It's also possible that the 'cull geometry that gets near' is a bad strategy and I should instead use boolean path ops to compute the stroke, then taking the stroke outline as the offset. I'm not sure - any tips are very welcome!

I've spent some time reading the Inkscape implementation, but then when I actually tried offsetting geometry it produced awful results for anything with significant curvature, so maybe that's not the place to look for inspiration.

These thoughts are a bit half-baked, so I may add to them as I understand things better.

derekdreery commented 1 year ago

I'm going to use this thread to put up ideas for discussion:

I've been thinking about the discussion around intersections, and have come up with the following type for intersections. Please feed back suggestions.

enum Intersection {
    Point {
        /// The `t` value of the first parameter.
        t_left: f64,
        t_right: f64,
    },
    Segment {
        t_left: Range<f64>,
        t_right: Range<f64>,
    },
}

Some discussion points: