Search code examples
kineticjshittestquadratic-curve

KineticJS Bounding Box for Quadratic Curve


I have implemented a class that uses the quadline that is shown in the Modify Curves With Anchor Points tutorial.

this.shape = new Kinetic.Shape({
    drawFunc: function(canvas) {
      var context = canvas.getContext();
      context.beginPath();
      context.moveTo(self.anchors[0].getX(), self.anchors[0].getY());
      for(var i = 1; i < self.anchors.length; i+=2){
        context.quadraticCurveTo(self.anchors[i].getX(), self.anchors[i].getY(), self.anchors[i+1].getX(), self.anchors[i+1].getY()); 
      }
      context.strokeStyle = 'red';
      context.lineWidth = 4;
      context.stroke();
    },
    drawHitFunc: function(canvas) {
      /** Some Hit Test Code **/
    }
  });
this.shape.on('dblclick', click);

I originally thought that this would be trivial, as I could just hit test a fat line, but apparently this does not work.

How would I make a shape that would follow this line for hit testing purposes?

UPDATE

I think that I am getting close using the following drawhitFunc

drawHitFunc: function(canvas) {
      var context = canvas.getContext();
      context.beginPath();
      context.moveTo(self.anchors[0].getX(), self.anchors[0].getY()-10);
      for(var i = 1; i < self.anchors.length; i+=2){
        context.quadraticCurveTo(self.anchors[i].getX(), self.anchors[i].getY()-10, self.anchors[i+1].getX(), self.anchors[i+1].getY()-10);
      }

      context.lineTo(self.anchors[self.anchors.length-1].getX(), self.anchors[self.anchors.length-1].getY() + 10);
      for(var i = self.anchors.length - 2; i >= 0; i-=2){
        context.quadraticCurveTo(self.anchors[i].getX(), self.anchors[i].getY()+10, self.anchors[i-1].getX(), self.anchors[i-1].getY()+10);
      }
      canvas.fillStroke(this);
    }

The problem with the above code is that as the curve has a greater slope the hit area gets smaller because of how the offset is calculated. I think I need to do some calculations to get an offset based on the line perpendicular to the anchor and its next control point.


Solution

  • Here’s how to define a “fat” bezier curve for use as a hit test area

    enter image description here

    This illustration shows the original bezier curve in red.

    The black filled area surrounding the curve is its “fat” hit test area.

    The fat area is actually a closed polyline-path.

    Here’s how to build the fat curve:

    • Travel along the curve from start to end in about 15-25 steps
    • At each step, calc a perpendicular line off that point on the curve
    • Extend the perpendicular line out from the curve by a distance (t)
    • Save the x/y endpoint of each extended perpendicular line
    • (These saved points will define the “fattened” polyline-path)

    Notes:

    If you move any anchor, you need to recalculate the fat path.

    If you want your curve to be quadratic instead of cubic, just make the 2 control points identical.

    For KineticJS hit-testing: use the polyline points to define the hit region using drawHitFunc.

    Making 25 steps on the curve will usually do a good job on even "kinked" curves. If you know you will have relatively smooth curves, you could take fewer steps. Fewer steps results in less precision in following the exact path of the curve.

    Here’s code and a Fiddle: http://jsfiddle.net/m1erickson/bKTew/

    <!doctype html>
    <html>
    <head>
    <link rel="stylesheet" type="text/css" media="all" href="css/reset.css" /> <!-- reset css -->
    <script type="text/javascript" src="http://code.jquery.com/jquery.min.js"></script>
    
    <style>
        body{ background-color: ivory; padding:20px; }
        #canvas{border:1px solid red;}
    </style>
    
    <script>
    $(function(){
    
        var canvas=document.getElementById("canvas");
        var ctx=canvas.getContext("2d");
    
        // endpoints,controlpoints
        var s={x:50,y:150};
        var c1={x:100,y:50};
        var c2={x:200,y:200};
        var e={x:250,y:50};
        var t=12;
    
        // polypoints is a polyline path defining the "fat" bezier
        var polypoints=[];
        var back=[];
        var p0=s;
    
        // manually calc the first startpoint
        var p=getCubicBezierXYatPercent(s,c1,c2,e,.02);
        var dx=p.x-s.x;
        var dy=p.y-s.y;
        var radians=Math.atan2(dy,dx)+Math.PI/2;
        polypoints.push(extendedPoint(s,radians,-t));
    
        // travel along the bezier curve gathering "fatter" points off the curve
        for(var i=.005;i<=1.01;i+=.04){
    
            // calc another further point
            var p1=getCubicBezierXYatPercent(s,c1,c2,e,i);
    
            // calc radian angle between p0 and new p1
            var dx=p1.x-p0.x;
            var dy=p1.y-p0.y;
            var radians=Math.atan2(dy,dx)+Math.PI/2;
    
            // calc a "fatter" version of p1 -- fatter by tolerance (t)
            // find a perpendicular line off p1 in both directions
            // then find both x/y's on that perp line at tolerance (t) off p1
            polypoints.push(extendedPoint(p1,radians,-t));
            back.push(extendedPoint(p1,radians,t));
            p0=p1;
    
        }
    
    
        // return data was collected in reverse order so reverse the return data
        back=back.reverse();
    
        // add the return data to the forward data to complete the path
        polypoints.push.apply(polypoints, back)
    
        // draw the "fat" bezier made by a polyline path
        ctx.beginPath();
        ctx.moveTo(polypoints[0].x,polypoints[0].y);
        for(var i=1;i<polypoints.length;i++){
            ctx.lineTo(polypoints[i].x,polypoints[i].y);
        }
        // be sure to close the path!
        ctx.closePath();
        ctx.fill();
    
    
        // just for illustration, draw original bezier
        ctx.beginPath();
        ctx.moveTo(s.x,s.y);
        ctx.bezierCurveTo(c1.x,c1.y,c2.x,c2.y,e.x,e.y);
        ctx.lineWidth=3;
        ctx.strokeStyle="red";
        ctx.stroke();
    
    
        // calc x/y at distance==radius from centerpoint==center at angle==radians
        function extendedPoint(center,radians,radius){
            var x = center.x + Math.cos(radians) * radius;
            var y = center.y + Math.sin(radians) * radius;
            return({x:x,y:y});
        }
    
    
        // cubic bezier XY from 0.00-1.00 
        // BTW, not really a percent ;)
        function getCubicBezierXYatPercent(startPt,controlPt1,controlPt2,endPt,percent){
            var x=CubicN(percent,startPt.x,controlPt1.x,controlPt2.x,endPt.x);
            var y=CubicN(percent,startPt.y,controlPt1.y,controlPt2.y,endPt.y);
            return({x:x,y:y});
        }
    
        // cubic helper formula at 0.00-1.00 distance
        function CubicN(pct, a,b,c,d) {
            var t2 = pct * pct;
            var t3 = t2 * pct;
            return a + (-a * 3 + pct * (3 * a - a * pct)) * pct
            + (3 * b + pct * (-6 * b + b * 3 * pct)) * pct
            + (c * 3 - c * 3 * pct) * t2
            + d * t3;
        }
    
    }); // end $(function(){});
    
    </script>
    
    </head>
    
    <body>
         <canvas id="canvas" width=300 height=300></canvas>
    </body>
    </html>