5

My problem statement:

I have a polyline, and I want to do variable width offset along the points, in just one direction. How can I do it? For the polyline, I just need to support straight lines, no need to support curves or arcs.

The polyline can be closed or open, and the offset is in only one direction-- for the sake of argument, let's just say that it's in the Left Hand Side direction.

enter image description here The above image almost encapsulates what I want to do; the only thing is, it is uniform offset throughout the polylines, whereby I want variable offset.

The problem is a lot more trickier than it first seems. There are a few libraries that not quite do. Let's go through them one by one.

Clipper

Clipper can handle polygon buffer, which means that the offsetting lines are created in both directions and finally a polygon is form around the line. So it's not suiting my needs. Furthermore it doesn't handle variable buffering.

There were some discussions on this on the forum involving the Clippers developers, but unfortunately nothing came out.

NetTopologySuite

NetTopologySuite has a VariableBuffer class, which can handle variable offset. But unfortunately NetTopologySuite can only handle polygon buffering ( whereby you convert a line into a polygon enclousing the line), and not polyline offsetting ( where by the polyline is offset in a single direction).

Also, it seems that with the above method NetTopologySuite will "blow up" the polygon in both direction, and one needs to set the BufferParameters.IsSingleSided=true in order to have a single sided polygon offset, but it's quite unclear how to use this in conjunction with VariableBuffer.

Cavalier contours

Cavalier countours, unlike the majority of the library out there, can do polyline offsetting ( which is what I want) in one direction only so that no polygon is formed. This is what I want, but unfortunately, it can't do variable width offsetting.

What about adapting current libraries to suit my needs?

There seems to be no trivial way of doing just this. Any ideas how this can be done?

Any solutions built on top of libraries in C#, C++ or C are welcomed.

Graviton
  • 81,782
  • 146
  • 424
  • 602
  • 2
    "Any solutions built on top of libraries in C#, C++ or C" is *very* broad. It's also an implied request for library recommendations. As you should know, Stack Overflow generally favors quite narrow and focused questions. If you want help with a generic language-agnostic algorithm then show us what you have, and use the `language-agnostic` tag without mentioning specific languages or libraries. If you want help with a specific library and language, then focus your question on those specifics instead. Mentioning other attempts and libraries are okay, but please try to focus the question on one. – Some programmer dude Jan 06 '22 at 08:11
  • 1
    @Someprogrammerdude, no, it is neither. It's not library recommendations because I am not specifically asking for library _recommendations_, but rather, I'm just asking for solutions to my problem in which libraries are welcome, it's no different from question such as [this](https://stackoverflow.com/q/18459150/3834). Using libraries to solve a programming problem is quite normal nowadays. It should be fine as long as you don't ask broad questions like "which library is the best" ( which should be closed because it's subjective, not because it's library related _per se_). – Graviton Jan 06 '22 at 09:30
  • "I can accept C# or C++ solutions"-- this just means that I can accept solutions in both. So are you saying that accepting two languages is just one too broad for you? It's pretty common that one programs in one language for the UI and programs in another for the other computations and use pinvoke to communicate between the two, and hence, both are and should be acceptable. How can it be too broad? – Graviton Jan 06 '22 at 09:30
  • 4
    I see no reason to close this question – vitaliis Jan 06 '22 at 18:50
  • Just a thought that may trigger ideas: If we consider the "white lines" in between the blue line of the image, we can define the problem differently: drawing variable thickness (white) lines with a fixed gap in between them. – c0der Jan 09 '22 at 09:56
  • @c0der, and how would _that_ solve my problem? I fail to see the connection. – Graviton Jan 09 '22 at 12:11
  • pick a library that does a good job on your kind of polylines with the constant offset. tweak its source code -- find the routine that calculates the offset segments (there should be one) and change it to produce the "diagonally" offset segments instead. the library will do its thing trimming the edges with _these_ segments. – Will Ness Jan 16 '22 at 22:47

3 Answers3

0

Indeed, the problem is more complicated with a variable offset. In the case with a single offset, the distance of a point p to the polyline is defined as the distance of the closest point of the polyline to p (see for example pointValidForOffset in https://github.com/jbuckmccready/CavalierContours/blob/master/include/cavc/polylineoffset.hpp )

The generalization to variable offset is not obvious.

A simple solution, in case of a simple polyline (no curves) is : Let F be the initial polyline.

  • draw G, the polyline far from the variable offset (see detail after)
  • connect the first point of F with the first point of G (creation of a segment A)
  • connect the last point of F with the last point of G (creation of a segment B)
  • we obtain a closed polygon P
  • To manage the intersections, deletion of loops: calculate the union of P with itself, then keep the contour that is not F, nor the segments A and B.

To draw G, the polyline far from the variable offset : This code works if you put the code in http://paperjs.org/ "Sketch" Here is the result :  a polyline with a fixed offset (red) and another with a variable size offset (green)

/* Computes a polyline with a fixed offset (red) 
 * and another with a variable size offset (green)
 */
    const points = [
        new Point(30, 40),
        new Point(100, 80),
        new Point(200, 60),
        new Point(250, 50),
        new Point(300, 70),
        new Point(350, 90),
        new Point(400, 60),
        new Point(450, 50),
        new Point(500, 70),
        new Point(550, 90),
        ];
    
    let poly1 = new Path();
    poly1.strokeColor = 'black';
    points.forEach(p =>  poly1.add(p));
    
    const fixOffs = 5;
    const offsets = [
        10, 20, 30, 20, 10, 10, 20, 20, 30, 30];
    
    let fix = [];
    let variable = [];
    
    const size = points.length;
    for(let i=0; i<size; ++i){
        let tangent;
        if(i===0){ // if first point
            tangent = points[1] - points[0];
        }else if(i+1===size){ // if last point
            tangent = points[i] - points[i-1];
        }else{ // mean of segment before and segment after point i
            tangent = (points[i] - points[i-1]) * 0.5 +
                      (points[i+1] - points[i]) * 0.5;
        }
        let normal = new Point(-tangent.y, tangent.x); // perpendicular vector
        normal = normal / normal.length; // normalize
        fix.push(points[i] + normal * fixOffs);
        variable.push(points[i] + normal * offsets[i]);
    }
    
    let polyFix = new Path();
    polyFix.strokeColor = 'red';
    fix.forEach(p =>  polyFix.add(p));
    
    let polyVar = new Path();
    polyVar.strokeColor = 'green';
    variable.forEach(p =>  polyVar.add(p));
Jerome Demantke
  • 161
  • 1
  • 8
  • `draw G, the polyline far from the variable offset` -- Your first step presupposes that we _already_ know the effect of variable offset. But isn't this is the whole point of the question? – Graviton Jan 07 '22 at 03:06
  • I have edited my answer to detail this. You have to calculate the normal at any point of the polyline. If the curve is smooth enough, the method I give can work, otherwise you may need a more complicated method. – Jerome Demantke Jan 07 '22 at 13:36
  • The way I see it, your algorithm doesn't handle even the slightly more complicated situation like concave polyline. – Graviton Jan 11 '22 at 04:08
  • For me, the only difference between concave and convex is the self-intersection of the curve and this is what I develop in the following points of my solution. Otherwise, I do not see any other difference, am I wrong? – Jerome Demantke Jan 11 '22 at 09:35
0

A standard offsetting algorithm would work as follows:

  1. Consider the line segments to be full lines. Offset all of them in the chosen direction by the given amount.
  2. Find the intersection of each pair of neighboring lines. That will be the new positions of the vertices.
  3. Check for vanishing segments and remove the respective vertices.

Note that Jerome's code offsets points, not segments. So you observed correctly that it fails to keep a fixed distance for very sharp angles. It also ignores the third step completely.

Now the difficult part is how to include a variable offset into this? If you had offset weights for each segment, it would be straight forward to integrate. But in your question you state that you have offset weights per vertex. Simply adding a 4th step that would move around the obtained vertices would invalidate the results of step 3. So I would rather search for a way to integrate the variable offset to the first step. In fact correct representation would require not only to offset the lines, but also rotating them. They would of course be no more parallel, but in my opinion this would represent the variable vertex offset nature the best. Again, if you want to reduce such difficulties, think about using per-segment weights which makes the whole problem much easier, since a weighted line offset can be easily integrated into step 1.

Update

I fixed and extended the example from Jerome's answer. @Jerome, thank you for introducing me to paper.js and for the initial inspiration. In my version I use variable widths per-segment, as a ratio of the fixed offset. Try it out here.

//the paper.js is really stupid: the search is hidden by the execution controls
//and once I rename this class to "Line" which would be more appropriate
//(but my original intent was indeed a Halfline), it throws errors, //so I keep the slightly misleading name "Halfline"
class Halfline {
    constructor(p, r)
    {
        const tangent = r - p;
        this.p = p;
        this.r = r;
        //implicit line equation
        this.a = -tangent.y;
        this.b = tangent.x;
        this.c = p.x * this.a + p.y * this.b;
        //I didn't normalize a and b, so here a unit normal
        this.n = new Point(this.a, this.b);
        this.n /= this.n.length;
    }
    //offset the line by t in the direction of its normal
    offset(t) {
        return new Halfline(this.p + this.n * t, this.r + this.n * t)
    }
    //line intersection (infinite lines)
    intersect(other) {
        const det = this.a * other.b - other.a * this.b;
        
        if (Math.abs(det) < 1e-5) //parallel
            return undefined;
        
        const x = (other.b * this.c - this.b * other.c) / det;
        const y = (this.a * other.c - other.a * this.c) / det;
        return new Point(x, y);
    }
}

//look at this great tutorial for details on helper functions: 
//https://algorithmtutor.com/Computational-Geometry/Check-if-two-Halfline-segment-intersect/
function fixOverlaps(polyline) {
    function on_segment(p1, p2, p) {
        return Math.min(p1.x, p2.x) <= p.x
               && p.x <= Math.max(p1.x, p2.x)
               && Math.min(p1.y, p2.y) <= p.y
               && p.y <= Math.max(p1.y, p2.y);
    }
    
    function cross_product(p1, p2) {
        return p1.x * p2.y - p2.x * p1.y;
    }
    
    function direction(p1, p2, p3) {
        return cross_product(p3 - p1, p2 - p1);
    }
    
    function segmentsIntersect(p1, p2, p3, p4) {
        d1 = direction(p3, p4, p1);
        d2 = direction(p3, p4, p2);
        d3 = direction(p1, p2, p3);
        d4 = direction(p1, p2, p4);

        return ( (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0))
                 && ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0)))
            || (d1 == 0 && on_segment(p3, p4, p1))
            || (d2 == 0 && on_segment(p3, p4, p2))
            || (d3 == 0 && on_segment(p1, p2, p3))
            || (d4 == 0 && on_segment(p1, p2, p4)) );
    }
    //search for self-intersections
        let intersectionsFound = true;
    while (intersectionsFound)
    {
        let result = [polyline[0]];
        for(let i = 1; i < polyline.length; ++i)
        {
            let anyIntersection = false;
            for(let j = i + 2; j < polyline.length; ++j)
                if (segmentsIntersect(polyline[i-1], polyline[i], polyline[j-1], polyline[j]))
                {
                    const s = new Halfline(polyline[i-1], polyline[i]);
                    const t = new Halfline(polyline[j-1], polyline[j]);
                    result.push(s.intersect(t))
                    anyIntersection = true;
                    i = j;
                    break;
                }
            result.push(polyline[i])
        }
        intersectionsFound = polyline.length > result.length;
        polyline = result;
    }
    
    return polyline;
}

const points = [
        new Point(30, 40),
        new Point(100, 200),
        new Point(200, 60),
        new Point(250, 50),
        new Point(300, 70),
        new Point(350, 250),
        new Point(400, 60),
        new Point(450, 50),
        new Point(500, 70),
        new Point(550, 90),
        ];
    
    let poly1 = new Path();
    poly1.strokeColor = 'black';
    points.forEach(p => poly1.add(p));
    
    const fixOffs = 10;
    const offsets = [1, 2, 3, 2, 1, 0.5, 2.5, 2, 3, 1].map(x => x * fixOffs);
    
    let fix = [];
    let variable = [];
    
    const size = points.length;
    for(let i = 0; i < size; ++i){
        let normal;
        if(i == 0 || i == size - 1) { // first or last
            const tangent = i == 0 ? points[i + 1] - points[i] : points[i] - points[i - 1];
            const normal = new Point(-tangent.y, tangent.x) / tangent.length;
            fix.push(points[i] + normal * fixOffs);
            variable.push(points[i] + normal * offsets[i]);
        } else {
            const prevSegment = new Halfline(points[i - 1], points[i]);
            const nextSegment = new Halfline(points[i], points[i + 1]);
            //CONSTANT
            const newConstVertex = prevSegment.offset(fixOffs).intersect(nextSegment.offset(fixOffs));
            fix.push(newConstVertex || (points[i] + prevSegment.n * fixOffs));
            //VARIABLE
            const newVarVertex = prevSegment.offset(offsets[i]).intersect(nextSegment.offset(offsets[i + 1]));
            variable.push(newVarVertex || (points[i] + prevSegment.n * offsets[i]));
        }
    }

    //Resolve vanishing segments
    const finalFix = fixOverlaps(fix);
    const finalVar = fixOverlaps(variable);
    
    let polyFix = new Path();
    polyFix.strokeColor = 'red';
    finalFix.forEach(p => polyFix.add(p));/**/

    let polyVar = new Path();
    polyVar.strokeColor = 'green';
    finalVar.forEach(p => polyVar.add(p));/**/

Note that it still does not handle all special cases. For example the corners of a non-closed polyline will produce strange shapes once the offset grows too large. Also note that the last two segments are parallel, an interesting choice of Jerome and good for debugging. I assigned them a different offset which forces the segments to give up their parallelity.

Isolin
  • 845
  • 7
  • 20
  • there is no "weight" mentioned anywhere in the question, only "width". – Will Ness Jan 16 '22 at 05:55
  • They denote the same thing :) I think weight fits a bit better since I build upon an algorithm for fixed offset, then scaling the offset per segment or per vertex means *weighting* it. For the most straight-forward case you take a fixed offset = 1. – Isolin Jan 16 '22 at 10:52
0

Offsetting a polyline can be done as follows:

  1. First construct a rectangle containing initial polyline and extended in all direction on the maximum offset value.
  2. Subdivide the rectangle on small pixels (with pixel size depending on the precision you need).
  3. Compute the distance from each pixel center to your polyline considering additional weight of segments requiring larger offset. The distance has to be with sign: positive from one side of polyline and negative from another. 2D raster with distances in each pixel is named sometimes distance map.
  4. Find the isoline of the distance field, which will give you the offset polyline.

For example, this C++ library provides most of the steps here: https://github.com/MeshRUs/MeshLib The only current limitation is that it computes distance map with signed distances only for closed polylines, but it can be solved in several ways (for example by extending the initial polyline beyond the containing rectangle).

Fedor
  • 17,146
  • 13
  • 40
  • 131