Hit detection when drawing lines in iOS
Asked Answered
S

2

13

I would like to allow the user to draw curves in such a way that no line can cross another line or even itself. Drawing the curves is no problem, and I even found that I can create a path that is closed and still pretty line-like by tracing the nodes of the line forwards and back and then closing the path.

Unfortunately, iOS only provides a test for whether a point is contained in a closed path (containsPoint: and CGPathContainsPoint). Unfortunately, a user can pretty easily move their finger fast enough that the touch points land on both sides of an existing path without actually being contained by that path, so testing the touch points is pretty pointless.

I can't find any "intersection" of paths method.

Any other thoughts on how to accomplish this task?

Stuart answered 26/6, 2011 at 5:46 Comment(2)
This question is similar to another SO Question. https://mcmap.net/q/752033/-cgpathref-intersection Those answers suggested looking at each individual pixel which will be slow. You can get a CGPathRef from your UIBezierPath object by myBezierPath.CGPathUnderstood
Good catch on the similar question. I'm working on an approach that compares successive bitmaps. Once I have demonstration code, I'll put it here. Meanwhile, I'll also check out the answers on that question.Stuart
S
6

Well, I did come up with a way to do this. It is imperfect, but I thought others might want to see the technique since this question was upvoted a few times. The technique I used draws all the items to be tested against into a bitmap context and then draws the new segment of the progressing line into another bitmap context. The data in those contexts is compared using bitwise operators and if any overlap is found, a hit is declared.

The idea behind this technique is to test each segment of a newly drawn line against all the previously drawn lines and even against earlier pieces of the same line. In other words, this technique will detect when a line crosses another line and also when it crosses over itself.

A sample app demonstrating the technique is available: LineSample.zip.

The core of hit testing is done in my LineView object. Here are two key methods:

- (CGContextRef)newBitmapContext {

    // creating b&w bitmaps to do hit testing
    // based on: http://robnapier.net/blog/clipping-cgrect-cgpath-531
    // see "Supported Pixel Formats" in Quartz 2D Programming Guide
    CGContextRef bitmapContext =
    CGBitmapContextCreate(NULL, // data automatically allocated
                          self.bounds.size.width,
                          self.bounds.size.height,
                          8, 
                          self.bounds.size.width,
                          NULL,
                          kCGImageAlphaOnly);
    CGContextSetShouldAntialias(bitmapContext, NO);
    // use CGBitmapContextGetData to get at this data

    return bitmapContext;
}


- (BOOL)line:(Line *)line canExtendToPoint:(CGPoint) newPoint {

    //  Lines are made up of segments that go from node to node. If we want to test for self-crossing, then we can't just test the whole in progress line against the completed line, we actually have to test each segment since one segment of the in progress line may cross another segment of the same line (think of a loop in the line). We also have to avoid checking the first point of the new segment against the last point of the previous segment (which is the same point). Luckily, a line cannot curve back on itself in just one segment (think about it, it takes at least two segments to reach yourself again). This means that we can both test progressive segments and avoid false hits by NOT drawing the last segment of the line into the test! So we will put everything up to the  last segment into the hitProgressLayer, we will put the new segment into the segmentLayer, and then we will test for overlap among those two and the hitTestLayer. Any point that is in all three layers will indicate a hit, otherwise we are OK.

    if (line.failed) {
        // shortcut in case a failed line is retested
        return NO;
    }
    BOOL ok = YES; // thinking positively

    // set up a context to hold the new segment and stroke it in
    CGContextRef segmentContext = [self newBitmapContext];
    CGContextSetLineWidth(segmentContext, 2); // bit thicker to facilitate hits
    CGPoint lastPoint = [[[line nodes] lastObject] point];
    CGContextMoveToPoint(segmentContext, lastPoint.x, lastPoint.y);
    CGContextAddLineToPoint(segmentContext, newPoint.x, newPoint.y);
    CGContextStrokePath(segmentContext);

    // now we actually test
    // based on code from benzado: https://mcmap.net/q/908443/-how-to-do-comparisons-of-bitmaps-in-ios/6515999#6515999
    unsigned char *completedData = CGBitmapContextGetData(hitCompletedContext);
    unsigned char *progressData = CGBitmapContextGetData(hitProgressContext);
    unsigned char *segmentData = CGBitmapContextGetData(segmentContext);

    size_t bytesPerRow = CGBitmapContextGetBytesPerRow(segmentContext);
    size_t height = CGBitmapContextGetHeight(segmentContext);
    size_t len = bytesPerRow * height;

    for (int i = 0; i < len; i++) {
        if ((completedData[i] | progressData[i]) & segmentData[i]) { 
            ok = NO; 
            break; 
        }
    }

    CGContextRelease(segmentContext);

    if (ok) {
        // now that we know we are good to go, 
        // we will add the last segment onto the hitProgressLayer
        int numberOfSegments = [[line nodes] count] - 1;
        if (numberOfSegments > 0) {
            // but only if there is a segment there!
            CGPoint secondToLastPoint = [[[line nodes] objectAtIndex:numberOfSegments-1] point];
            CGContextSetLineWidth(hitProgressContext, 1); // but thinner
            CGContextMoveToPoint(hitProgressContext, secondToLastPoint.x, secondToLastPoint.y);
            CGContextAddLineToPoint(hitProgressContext, lastPoint.x, lastPoint.y);
            CGContextStrokePath(hitProgressContext);
        }
    } else {
        line.failed = YES;
        [linesFailed addObject:line];
    }
    return ok;
}

I'd love to hear suggestions or see improvements. For one thing, it would be a lot faster to only check the bounding rect of the new segment instead of the whole view.

Stuart answered 29/6, 2011 at 20:29 Comment(3)
Fair warning: I've already found a few bugs in the sample app, so make sure you pay attention to your own implementation. The basic technique seems to work, just a few implementation issues that could be improved. I'll fiddle with the sample some more and keep it updated, but my main focus will be elsewhere.Stuart
Hi @EFC, I'm kinda new to the community and a newbie iOS programmer, can you point me specifically where is the code where It prevents to intersect itself? I only need that part.Cabriole
To prevent the intersect I am simply looking to see if any bits are in common between old and new segments. The if ((completedData[i] | progressData[i]) & segmentData[i]) { line is what does that actual test. This test came from https://mcmap.net/q/908443/-how-to-do-comparisons-of-bitmaps-in-ios.Stuart
A
1

Swift 4, answer is based on CGPath Hit Testing - Ole Begemann (2012)

From Ole Begemann blog:

contains(point: CGPoint)

This function is helpful if you want to hit test on the entire region the path covers. As such, contains(point: CGPoint) doesn’t work with unclosed paths because those don’t have an interior that would be filled.

copy(strokingWithWidth lineWidth: CGFloat, lineCap: CGLineCap, lineJoin: CGLineJoin, miterLimit: CGFloat, transform: CGAffineTransform = default) -> CGPath

This function creates a mirroring tap target object that only covers the stroked area of the path. When the user taps on the screen, we iterate over the tap targets rather than the actual shapes.


My solution in code

I use a UITapGestureRecognizer linked to the function tap():

var bezierPaths = [UIBezierPath]()   // containing all lines already drawn
var tappedPaths = [CAShapeLayer]()

@IBAction func tap(_ sender: UITapGestureRecognizer) {        
    let point = sender.location(in: imageView)

    for path in bezierPaths {
        // create tapTarget for path
        if let target = tapTarget(for: path) {
            if target.contains(point) {
                tappedPaths.append(layer)
            }
        }
    }
}

fileprivate func tapTarget(for path: UIBezierPath) -> UIBezierPath {

    let targetPath = path.copy(strokingWithWidth: path.lineWidth, lineCap: path..lineCapStyle, lineJoin: path..lineJoinStyle, miterLimit: path.miterLimit)

    return UIBezierPath.init(cgPath: targetPath)
}
Alkanet answered 29/12, 2018 at 9:40 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.