Identify face of a cube hit on touches began in Swift - SceneKit
Asked Answered
G

2

6

I'm tying to create an app with SceneKit to solve a Rubik's Cube. I've made my own dae file for the cube. Upon touches began I have the object that's been hit

  func tapGesture(sender: UITapGestureRecognizer){

    // check what nodes are tapped
    var p = sender.locationInView(sceneView)
    var hitResults = sceneView.hitTest(p, options: nil)
    if hitResults.count > 0
    {

        var hitnode = (hitResults.first)!.node
        print("\nName of node hit is \(hitnode.name)")
        
        //var indexvalue = hitResults.first?.faceIndex
        //print(indexvalue)
    }
  }

How can I find exactly which face of the cube is being hit?

Geest answered 2/2, 2016 at 12:17 Comment(0)
C
12

faceIndex looks promising, but will not actually get something you're likely to consider useful. The "faces" counted by that property are the tessellation of the mesh, so a cube won't be a collection of six quads, it'll be twelve triangles. (Or more: in some cases, even a flat-sided cube will be tessellated with more than one quad / two triangles per side. If you're using SCNBox you control these with widthSegmentCount etc.)

Instead — especially if your cube is an SCNBox — the easiest solution might be to leverage this interesting behavior of that class:

You can assign up to six SCNMaterial instances to a box—one for each side—with its materials property. The SCNBox class automatically creates SCNGeometryElement objects as needed to handle the number of materials.

So, if you assign six materials, you'll get one for each side:

let front = SCNMaterial()
let right = SCNMaterial()
let back = SCNMaterial()
let left = SCNMaterial()
let top = SCNMaterial()
let bottom = SCNMaterial()
cube.materials = [ front, right, back, left, top, bottom ]

And in so doing, your SCNBox will have six geometry elements — one for each material, which corresponds to one for each side.

Now, you can use hit testing to find out which geometry element was clicked:

if let result = hitResults.first {
    let node = result.node

    // Find the material for the clicked element
    // (Indices match between the geometryElements and materials arrays)
    let material = node.geometry!.materials[result.geometryIndex]

    // Do something with that material, for example:
    let highlight = CABasicAnimation(keyPath: "diffuse.contents")
    highlight.toValue = NSColor.redColor()
    highlight.duration = 1.0
    highlight.autoreverses = true
    highlight.removedOnCompletion = true
    material.addAnimation(highlight, forKey: nil)
}

Or if you're not highlighting and want to use the face index for logic, here's the beginning of something you could use for that:

enum CubeFace: Int {
    case Front, Right, Back, Left, Top, Bottom
}

// when processing hit test result:
print("hit face: \(CubeFace(rawValue: result.geometryIndex))")
Campanile answered 2/2, 2016 at 21:52 Comment(4)
Thank you @rickster. That hit the bulls eye. Now I can find the material that was hit in addition to object that was hit.Geest
Would another alternative be using the localNormal value? That seems to work in testing, and you wouldn't need to explicitly assign materials to each side? @NishanthVemulaInterferometer
the "faces" counted by that property are the tessellation of the mesh => is this determined strictly by the original 3D mesh model, or does scenekit layer extra calculations on top? that is, if the original mesh model contains 6 quads/faces for a cube (and not 12 triangles), the "faces" counted would be 6?Interferometer
AFIAK SceneKit can’t render quad meshes, so a cube is always at least 12 triangles. (It could also be more, like if you increase the segment count on SCNBox.)Campanile
I
5

We use this function to determine the face hit based on the localNormal value from the SCNHitResult.

This function concludes a face has been hit if the magnitude of an axis is 1.

It assumes exactly one and only one axis will have a magnitude equal to 1. The code will break otherwise. It also assumes a SCNBox geometry.

In testing, this seems to work (for SCNBox geometries). The only complication is the localNormal value doesn't always return clean 0 values. Sometimes it returns values like -5.96046448e-08 so we use the round function to be safe in case the same applies to values near 1 but not exactly 1.

We are new to SceneKit and 3D in general, so the code could be flawed. Please comment if you spot issues or potential optimizations.

private func getHitFaceFromNormal(normal: SCNVector3) {
    if round(normal.x) == -1 {
        // Left face hit
    } else if round(normal.x) == 1 {
        // Right face hit
    } else if round(normal.y) == -1 {
        // Bottom face hit
    } else if round(normal.y) == 1 {
        // Top face hit
    } else if round(normal.z) == -1 {
        // Back face hit
    } else if round(normal.z) == 1 {
        // Front face hit
    } else {
        // Error, no face detected
    }
}
Interferometer answered 12/9, 2016 at 22:11 Comment(3)
This is a good idea! I'll try it and let you know if I have any problems. My coding skills are WEAK.Baranowski
Your assumption of exactly one and only one axis having a magnitude equal to 1 applies to my case. Yet, the cubelets of the Rubik's cube are curved at the edges. If we consider the case where the point where hitTest is being done is on the edge, I think the approach by @Campanile would be more suitable.Geest
This looks most useful of all for finding the location of a touch on a sphere. If the sphere is centred on the origin then the local normal is the location vector of the touch. I'm going to try it. Thanks.Fiden

© 2022 - 2024 — McMap. All rights reserved.