Hit Detection When Drawing Lines in iOS

Hit detection when drawing lines in iOS

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: http://stackoverflow.com/questions/6515885/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.

How to do hit detection in core graphics

There are various approaches, depending on exactly what your end-goal is.

One approach:

  • calculate the "degrees-per-slice" ... 360 / 20 = 18
  • get the angle from the center point to the touch point
  • "fix" the angle by 1/2 of the slice width (since the slices don't start at zero)
  • divide that angle by degrees-per-slice to get the slice number

Use these two extensions to make it easy to get the angle (in degrees):

extension CGFloat {
var degrees: CGFloat {
return self * CGFloat(180) / .pi
}
var radians: CGFloat {
return self * .pi / 180.0
}
}

extension CGPoint {
func angle(to otherPoint: CGPoint) -> CGFloat {
let pX = otherPoint.x - x
let pY = otherPoint.y - y
let radians = atan2f(Float(pY), Float(pX))
var degrees = CGFloat(radians).degrees
while degrees < 0 {
degrees += 360
}
return degrees
}
}

And, in the code you posted, in your DrawTest class, change didHit to:

static func didHit(_ point: CGPoint, in bounds: CGRect){

let c: CGPoint = CGPoint(x: bounds.midX, y: bounds.midY)
let angle = c.angle(to: point)
var fixedAngle = Int(angle) + 99 // 90 degrees + 1/2 of slice width
if fixedAngle >= 360 {
fixedAngle -= 360
}
print("HIT:", fixedAngle / 18)

}

and include the bounds when you call it from DartBoardView class as:

@objc
func clickAction(sender : UITapGestureRecognizer) {
if sender.state == .recognized
{
let loc = sender.location(in: self)
// include self's bounds
DrawTest.didHit(loc, in: bounds)
}
}

Drawbacks include:

  • you'd also need to check the "line length" to make sure it doesn't extend outside the circle
  • you don't have easy access to the slice bezier paths (if you want to do something else with them)

Another approach would be to use shape layers for each slice, making it easier to track the bezier paths.

Start with a Struct for the slices:

struct Slice {
var color: UIColor = .white
var path: UIBezierPath = UIBezierPath()
var shapeLayer: CAShapeLayer = CAShapeLayer()
var key: Int = 0
}

The DartBoardView class becomes (note: it uses the same CGFloat extension from above):

extension CGFloat {
var degrees: CGFloat {
return self * CGFloat(180) / .pi
}
var radians: CGFloat {
return self * .pi / 180.0
}
}

class DartBoardView: UIView {

// array of slices
var slices: [Slice] = []

// slice width in degrees
let sliceWidth: CGFloat = 360.0 / 20.0

// easy to understand 12 o'clock (3 o'clock is Zero)
let twelveOClock: CGFloat = 270

override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}

func commonInit() -> Void {

let dark = UIColor(red: 0.235, green: 0.208, blue: 0.208, alpha: 1.000)
let light = UIColor(red: 0.435, green: 0.408, blue: 0.408, alpha: 1.000)

for slice in 0..<20 {
let sliceColor = slice % 2 == 1 ? dark : light
let s = Slice(color: sliceColor, key: slice)
s.shapeLayer.fillColor = s.color.cgColor
layer.addSublayer(s.shapeLayer)
slices.append(s)
}

let gesture = UITapGestureRecognizer(target: self, action: #selector(self.clickAction(sender:)))
addGestureRecognizer(gesture)

}

@objc
func clickAction(sender : UITapGestureRecognizer) {
if sender.state == .recognized
{
let loc = sender.location(in: self)
if let s = slices.first(where: { $0.path.contains(loc) }) {
print("HIT:", s.key)
} else {
print("Tapped outside the circle!")
}
}
}

override func layoutSubviews() {
super.layoutSubviews()

let c: CGPoint = CGPoint(x: bounds.midX, y: bounds.midY)
let radius: CGFloat = bounds.midX

// slice width in radians
let ww: CGFloat = sliceWidth.radians

// start 1/2 sliceWidth less than 12 o'clock
var startDegrees: CGFloat = twelveOClock.radians - (ww * 0.5)

for i in 0..<slices.count {

let endDegrees: CGFloat = startDegrees + ww

let pth: UIBezierPath = UIBezierPath()
pth.addArc(withCenter: c, radius: radius, startAngle: startDegrees, endAngle: endDegrees, clockwise: true)
pth.addLine(to: c)
pth.close()

slices[i].path = pth
slices[i].shapeLayer.path = pth.cgPath

startDegrees = endDegrees

}

}

}

And here's an example controller class to demonstrate:

class DartBoardViewController: UIViewController {

let dartBoard = DartBoardView()

override func viewDidLoad() {
super.viewDidLoad()

dartBoard.translatesAutoresizingMaskIntoConstraints = false

view.addSubview(dartBoard)

let g = view.safeAreaLayoutGuide

NSLayoutConstraint.activate([
dartBoard.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
dartBoard.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
dartBoard.heightAnchor.constraint(equalTo: dartBoard.widthAnchor),
dartBoard.centerYAnchor.constraint(equalTo: g.centerYAnchor),
])

dartBoard.backgroundColor = .black
}

}

Edit

Not as complex as it may seem.

Here's an implementation of a full Dart Board (without the numbers - I'll leave that as an exercise for you):

Segment Struct

struct Segment {
var value: Int = 0
var multiplier: Int = 1
var color: UIColor = .cyan
var path: UIBezierPath = UIBezierPath()
var layer: CAShapeLayer = CAShapeLayer()
}

DartBoardView class

class DartBoardView: UIView {

var doubleSegments: [Segment] = [Segment]()
var outerSingleSegments: [Segment] = [Segment]()
var tripleSegments: [Segment] = [Segment]()
var innerSingleSegments: [Segment] = [Segment]()
var singleBullSegment: Segment = Segment()
var doubleBullSegment: Segment = Segment()

var allSegments: [Segment] = [Segment]()

let boardLayer: CAShapeLayer = CAShapeLayer()

let darkColor: UIColor = UIColor(white: 0.1, alpha: 1.0)
let lightColor: UIColor = UIColor(red: 0.975, green: 0.9, blue: 0.8, alpha: 1.0)
let darkRedColor: UIColor = UIColor(red: 0.8, green: 0.1, blue: 0.1, alpha: 1.0)
let darkGreenColor: UIColor = UIColor(red: 0.0, green: 0.5, blue: 0.3, alpha: 1.0)

override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() -> Void {

layer.addSublayer(boardLayer)
boardLayer.fillColor = UIColor.black.cgColor

// points starting at 3 o'clock
let values: [Int] = [
6, 10, 15, 2, 17, 3, 19, 7, 16, 8, 11, 14, 9, 12, 5, 20, 1, 18, 4, 13,
]

// local vars for reuse
var seg: Segment = Segment()
var c: UIColor = .white

// doubles and triples
for i in 0..<values.count {
c = i % 2 == 1 ? darkRedColor : darkGreenColor

seg = Segment(value: values[i],
multiplier: 2,
color: c,
layer: CAShapeLayer())
layer.addSublayer(seg.layer)
doubleSegments.append(seg)

seg = Segment(value: values[i],
multiplier: 3,
color: c,
layer: CAShapeLayer())
layer.addSublayer(seg.layer)
tripleSegments.append(seg)
}

// singles
for i in 0..<values.count {
c = i % 2 == 1 ? darkColor : lightColor

seg = Segment(value: values[i],
multiplier: 1,
color: c,
layer: CAShapeLayer())
layer.addSublayer(seg.layer)
outerSingleSegments.append(seg)

seg = Segment(value: values[i],
multiplier: 1,
color: c,
layer: CAShapeLayer())
layer.addSublayer(seg.layer)
innerSingleSegments.append(seg)
}

// bull and double bull
seg = Segment(value: 25,
multiplier: 1,
color: darkGreenColor,
layer: CAShapeLayer())
layer.addSublayer(seg.layer)
singleBullSegment = seg

seg = Segment(value: 25,
multiplier: 2,
color: darkRedColor,
layer: CAShapeLayer())
layer.addSublayer(seg.layer)
doubleBullSegment = seg

let gesture = UITapGestureRecognizer(target: self, action: #selector(self.clickAction(sender:)))
addGestureRecognizer(gesture)

}

@objc
func clickAction(sender : UITapGestureRecognizer) {
if sender.state == .recognized
{
let loc = sender.location(in: self)
if let s = allSegments.first(where: { $0.path.contains(loc) }) {
print("HIT:", s.multiplier == 3 ? "Triple" : s.multiplier == 2 ? "Double" : "Single", s.value)
} else {
print("Tapped outside!")
}
}
}

override func layoutSubviews() {
super.layoutSubviews()

// initialize local variables for reuse / readability
var startAngle: CGFloat = 0

var outerDoubleRadius: CGFloat = 0.0
var innerDoubleRadius: CGFloat = 0.0
var outerTripleRadius: CGFloat = 0.0
var innerTripleRadius: CGFloat = 0.0
var outerBullRadius: CGFloat = 0.0
var innerBullRadius: CGFloat = 0.0

// initialize local constants
let viewCenter: CGPoint = CGPoint(x: bounds.midX, y: bounds.midY)

// leave 20% for the numbers area
let diameter = bounds.width * 0.8

// dart board radii in mm
let specRadii: [CGFloat] = [
170, 162, 107, 99, 16, 6
]

// convert to view size
let factor: CGFloat = (diameter * 0.5) / specRadii[0]

outerDoubleRadius = specRadii[0] * factor
innerDoubleRadius = specRadii[1] * factor
outerTripleRadius = specRadii[2] * factor
innerTripleRadius = specRadii[3] * factor
outerBullRadius = specRadii[4] * factor
innerBullRadius = specRadii[5] * factor

let wireColor: UIColor = UIColor(white: 0.8, alpha: 1.0)

let wedgeWidth: CGFloat = 360.0 / 20.0
let incAngle: CGFloat = wedgeWidth.radians
startAngle = -(incAngle * 0.5)

var path: UIBezierPath = UIBezierPath()

// outer board layer
path = UIBezierPath(ovalIn: bounds)
boardLayer.path = path.cgPath

for i in 0..<20 {
let endAngle = startAngle + incAngle

var shape = doubleSegments[i].layer
path = UIBezierPath()
path.addArc(withCenter: viewCenter, radius: outerDoubleRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
path.addArc(withCenter: viewCenter, radius: innerDoubleRadius, startAngle: endAngle, endAngle: startAngle, clockwise: false)
path.close()
shape.path = path.cgPath

doubleSegments[i].path = path

shape.fillColor = doubleSegments[i].color.cgColor
shape.strokeColor = wireColor.cgColor
shape.borderWidth = 1.0
shape.borderColor = wireColor.cgColor

shape = outerSingleSegments[i].layer
path = UIBezierPath()
path.addArc(withCenter: viewCenter, radius: innerDoubleRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
path.addArc(withCenter: viewCenter, radius: outerTripleRadius, startAngle: endAngle, endAngle: startAngle, clockwise: false)
path.close()
shape.path = path.cgPath

outerSingleSegments[i].path = path

shape.fillColor = outerSingleSegments[i].color.cgColor
shape.strokeColor = wireColor.cgColor
shape.borderWidth = 1.0
shape.borderColor = wireColor.cgColor

shape = tripleSegments[i].layer
path = UIBezierPath()
path.addArc(withCenter: viewCenter, radius: outerTripleRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
path.addArc(withCenter: viewCenter, radius: innerTripleRadius, startAngle: endAngle, endAngle: startAngle, clockwise: false)
path.close()
shape.path = path.cgPath

tripleSegments[i].path = path

shape.fillColor = tripleSegments[i].color.cgColor
shape.strokeColor = wireColor.cgColor
shape.borderWidth = 1.0
shape.borderColor = wireColor.cgColor

shape = innerSingleSegments[i].layer
path = UIBezierPath()
path.addArc(withCenter: viewCenter, radius: innerTripleRadius, startAngle: startAngle, endAngle: endAngle, clockwise: true)
path.addArc(withCenter: viewCenter, radius: outerBullRadius, startAngle: endAngle, endAngle: startAngle, clockwise: false)
path.close()
shape.path = path.cgPath

innerSingleSegments[i].path = path

shape.fillColor = innerSingleSegments[i].color.cgColor
shape.strokeColor = wireColor.cgColor
shape.borderWidth = 1.0
shape.borderColor = wireColor.cgColor

startAngle = endAngle
}

let singleBullPath = UIBezierPath(ovalIn: CGRect(x: viewCenter.x - outerBullRadius, y: viewCenter.y - outerBullRadius, width: outerBullRadius * 2, height: outerBullRadius * 2))
let doubleBullPath = UIBezierPath(ovalIn: CGRect(x: viewCenter.x - innerBullRadius, y: viewCenter.y - innerBullRadius, width: innerBullRadius * 2, height: innerBullRadius * 2))

var shape = singleBullSegment.layer
singleBullPath.append(doubleBullPath)
singleBullPath.usesEvenOddFillRule = true
shape.fillRule = .evenOdd

shape.path = singleBullPath.cgPath

singleBullSegment.path = singleBullPath

shape.fillColor = singleBullSegment.color.cgColor
shape.strokeColor = wireColor.cgColor
shape.borderWidth = 1.0
shape.borderColor = wireColor.cgColor

shape = doubleBullSegment.layer
shape.path = doubleBullPath.cgPath
doubleBullSegment.path = doubleBullPath

shape.fillColor = doubleBullSegment.color.cgColor
shape.strokeColor = wireColor.cgColor
shape.borderWidth = 1.0
shape.borderColor = wireColor.cgColor

// append all segments for hit-testing
allSegments = []
allSegments.append(contentsOf: tripleSegments)
allSegments.append(contentsOf: outerSingleSegments)
allSegments.append(contentsOf: doubleSegments)
allSegments.append(contentsOf: innerSingleSegments)
allSegments.append(singleBullSegment)
allSegments.append(doubleBullSegment)

}
}

CGFloat extension

extension CGFloat {
var degrees: CGFloat {
return self * CGFloat(180) / .pi
}
var radians: CGFloat {
return self * .pi / 180.0
}
}

Example view controller

class DartBoardViewController: UIViewController {

let dartBoard = DartBoardView()

override func viewDidLoad() {
super.viewDidLoad()

dartBoard.translatesAutoresizingMaskIntoConstraints = false

view.addSubview(dartBoard)

let g = view.safeAreaLayoutGuide

NSLayoutConstraint.activate([
dartBoard.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 20.0),
dartBoard.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -20.0),
dartBoard.heightAnchor.constraint(equalTo: dartBoard.widthAnchor),
dartBoard.centerYAnchor.constraint(equalTo: g.centerYAnchor),
])

dartBoard.backgroundColor = .clear
}

}

Result:

Sample Image

and debug output from a few taps:

HIT: Double 20
HIT: Single 18
HIT: Triple 2
HIT: Single 25
HIT: Double 25

Spritkit : Detect collision while drawing line using finger

you are creating your physics body with an edge loop. Apple defines edge loops as...

An edge has no volume or mass and is always treated as if the isDynamic property is equal to false. Edges may only collide with volume-based physics bodies.

changing your physics body to this works

currentLineNode.physicsBody = SKPhysicsBody(rectangleOf: currentLineNode.frame.size, center: CGPoint(x: startPoint.x + currentLineNode.frame.size.width / 2, y: startPoint.y + currentLineNode.frame.size.height / 2)) 

also should be noted that changing your physics body in touchesEnded is redundant and adds nothing. I removed it from touchesEnded and it works fine

Drawing line with SpriteKit and detecting collision

Unfortunately nodes in SpriteKit cannot be concave (http://mathworld.wolfram.com/ConcavePolygon.html), with lines that cross over each other.

You will have to create separate nodes for each lines between adjacent points and then possibly join them together. you could do this either by:

  1. Create the 1st segment and then add subsequent segments as children
    of that 1st segment.
  2. Create a non-visible node for the whole line and add wll the line
    segments as children of that node.
  3. Create physicsbodies for each segment and join them with
    SKPhysicsJointFixed.

How can I collide line with line itself?

In the example code, everything is just rendered on the screen. No part of a collision detection system is implemented.

For implementing one of those, one of the simplest ways is to put all the points that make up the line in an NSMutableArray, and every time you want to draw a new point, you can check it against all the points contained in the array. If the new point is already contained in the array, then you have a collision between the line and the new point you are trying to draw.

From there on, you can research standard collision systems, and implement one of those.
Cocos2D also supports 2 physics engines: Box2D and Chipmunk, both of which have collision detection of their own. For efficiency, you might want to use one of those instead of implementing your own system.

Drawing a line through finger touch on iPhone

I am using this:

   - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
self.currentPath = [UIBezierPath bezierPath];
currentPath.lineWidth = 3.0;
[currentPath moveToPoint:[touch locationInView:self]];
[paths addObject:self.currentPath];
}

- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
[self.currentPath addLineToPoint:[touch locationInView:self]];
[self setNeedsDisplay];
}

- (void)drawRect:(CGRect)rect {
[[UIColor redColor] set];
for (UIBezierPath *path in paths) {
[path stroke];
}
}

You can get related class reference from apple.



Related Topics



Leave a reply



Submit