ios – Swift – Detect Tap on child SCNNode not Parent


Hello I have a simple interactive globe represented by dots. Every dot is a SCNNode and the globe is also a SCNNode. The Entire globe node is the parent and the smaller dots within the globe are children. I want to detect taps on the child nodes. When I try doing so the tap gestures are detected only for the parent node and because of this I cannot determine which child node is clicked. Ultimately I want to click on a child node (dot) and determine the SCNVector3 of the position clicked so I can convert to coordinates.

Ive been working on the function touchesBegan but this picks up clicks on the parent earth node.

import Foundation
import SceneKit
import CoreImage
import SwiftUI
import MapKit

public typealias GenericController = UIViewController
public typealias GenericColor = UIColor
public typealias GenericImage = UIImage

public class GlobeViewController: GenericController {
    //@Binding var showProf: Bool
    var nodePos: CGPoint? = nil
    public var earthNode: SCNNode!
    private var sceneView : SCNView!
    private var cameraNode: SCNNode!
    private var worldMapImage: CGImage {
        guard let image = UIImage(named: "earth-dark")?.cgImage else {
            fatalError("Not found")
        }
        return image
    }

    private lazy var imgData: CFData = {
        guard let imgData = worldMapImage.dataProvider?.data else { fatalError("Could not fetch data from world map image.") }
        return imgData
    }()

    private lazy var worldMapWidth: Int = {
        return worldMapImage.width
    }()

    public var earthRadius: Double = 1.0 {
        didSet {
            if let earthNode = earthNode {
                earthNode.removeFromParentNode()
                setupGlobe()
            }
        }
    }
   
    public var dotSize: CGFloat = 0.005 {
        didSet {
            if dotSize != oldValue {
                setupDotGeometry()
            }
        }
    }
    
    public var enablesParticles: Bool = true {
        didSet {
            if enablesParticles {
                setupParticles()
            } else {
                sceneView.scene?.rootNode.removeAllParticleSystems()
            }
        }
    }
    
    public var particles: SCNParticleSystem? {
        didSet {
            if let particles = particles {
                sceneView.scene?.rootNode.removeAllParticleSystems()
                sceneView.scene?.rootNode.addParticleSystem(particles)
            }
        }
    }
    
    public var earthColor: Color = .earthColor {
        didSet {
            if let earthNode = earthNode {
                earthNode.geometry?.firstMaterial?.diffuse.contents = earthColor
            }
        }
    }
    
    public var glowColor: Color = .earthGlow {
        didSet {
            if let earthNode = earthNode {
                earthNode.geometry?.firstMaterial?.emission.contents = glowColor
            }
        }
    }
    
    public var reflectionColor: Color = .earthReflection {
        didSet {
            if let earthNode = earthNode {
                earthNode.geometry?.firstMaterial?.emission.contents = glowColor
            }
        }
    }

    public var glowShininess: CGFloat = 1.0 {
        didSet {
            if let earthNode = earthNode {
                earthNode.geometry?.firstMaterial?.shininess = glowShininess
            }
        }
    }

    private var dotRadius: CGFloat {
        if dotSize > 0 {
             return dotSize
        }
        else {
            return 0.01 * CGFloat(earthRadius) / 1.0
        }
    }

    private var dotCount = 50000
    
    public init(earthRadius: Double) {//, showProf: Binding<Bool>
        self.earthRadius = earthRadius
        //self._showProf = showProf
        super.init(nibName: nil, bundle: nil)
    }
    
    public init(earthRadius: Double, dotCount: Int) {//, showProf: Binding<Bool>
        self.earthRadius = earthRadius
        self.dotCount = dotCount
        //self._showProf = showProf
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    public override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        guard let touch = touches.first, touch.view == self.sceneView else {
            return
        }

        let touchLocation = touch.location(in: sceneView)
        let hitTestResults = sceneView.hitTest(touchLocation, options: nil)
        
        let touchLocation3D = sceneView.unprojectPoint(SCNVector3(Float(touchLocation.x), Float(touchLocation.y), 0.0))
        
        print(touchLocation3D)
        
        if let tappedNode = hitTestResults.first?.node {
            print(tappedNode)
            // Handle the tapped node
            if tappedNode.name == "NewYorkDot" {
                // This is the New York dot, perform your action here
                print("Tapped on New York! Position: \(tappedNode.position)")
            } else if tappedNode.name == "RegularDot" {
                // Handle other nodes if needed
                print("Tapped on a regular dot. Position: \(tappedNode.position)")
            }
        }
    }

    public override func viewDidLoad() {
        super.viewDidLoad()
        setupScene()
        
        setupParticles()
        
        setupCamera()
        setupGlobe()
        
        setupDotGeometry()
    }
    
    private func setupScene() {
        let scene = SCNScene()
        sceneView = SCNView(frame: view.frame)
    
        sceneView.scene = scene
        
//        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:)))
//        sceneView.addGestureRecognizer(tapGesture)

        sceneView.showsStatistics = true
        sceneView.backgroundColor = .clear
        sceneView.allowsCameraControl = true
        sceneView.isUserInteractionEnabled = true
        
        self.view.addSubview(sceneView)
    }
        
    private func setupParticles() {
        guard let stars = SCNParticleSystem(named: "StarsParticles.scnp", inDirectory: nil) else { return }
        stars.isLightingEnabled = false
                
        if sceneView != nil {
            sceneView.scene?.rootNode.addParticleSystem(stars)
        }
    }
    
    private func setupCamera() {
        self.cameraNode = SCNNode()
        cameraNode.camera = SCNCamera()
        cameraNode.position = SCNVector3(x: 0, y: 0, z: 5)
        
        sceneView.scene?.rootNode.addChildNode(cameraNode)
        
//        let textureMap = generateTextureMap(dots: dotCount, sphereRadius: CGFloat(earthRadius))
//        let newYork = CLLocationCoordinate2D(latitude: 44.0682, longitude: -121.3153)
//        let newYorkDot = closestDotPosition(to: newYork, in: textureMap)
//        var position: SCNVector3?
//
//        if let pos = textureMap.first(where: { $0.x == newYorkDot.x && $0.y == newYorkDot.y }) {
//            position = pos.position
//        }

//        Timer.scheduledTimer(withTimeInterval: 8.0, repeats: false) { _ in
//            if let pos = position {
//                self.centerCameraOnDot(dotPosition: pos)
//            }
//        }
    }

    private func setupGlobe() {
        self.earthNode = EarthNode(radius: earthRadius, earthColor: earthColor, earthGlow: glowColor, earthReflection: reflectionColor)
        sceneView.scene?.rootNode.addChildNode(earthNode)
    }

    private func setupDotGeometry() {
        let textureMap = generateTextureMap(dots: dotCount, sphereRadius: CGFloat(earthRadius))

        let newYork = CLLocationCoordinate2D(latitude: 44.0682, longitude: -121.3153)
        let newYorkDot = closestDotPosition(to: newYork, in: textureMap)

        let dotColor = GenericColor(white: 1, alpha: 1)
        let oceanColor = GenericColor(cgColor: UIColor.systemRed.cgColor)
        let highlightColor = GenericColor(cgColor: UIColor.systemRed.cgColor)
        
        // threshold to determine if the pixel in the earth-dark.jpg represents terrain (0.03 represents rgb(7.65,7.65,7.65), which is almost black)
        let threshold: CGFloat = 0.03
        
        let dotGeometry = SCNSphere(radius: dotRadius)
        dotGeometry.firstMaterial?.diffuse.contents = dotColor
        dotGeometry.firstMaterial?.lightingModel = SCNMaterial.LightingModel.constant
        
        let highlightGeometry = SCNSphere(radius: dotRadius)
        highlightGeometry.firstMaterial?.diffuse.contents = highlightColor
        highlightGeometry.firstMaterial?.lightingModel = SCNMaterial.LightingModel.constant
        
        let oceanGeometry = SCNSphere(radius: dotRadius)
        oceanGeometry.firstMaterial?.diffuse.contents = oceanColor
        oceanGeometry.firstMaterial?.lightingModel = SCNMaterial.LightingModel.constant
        
        var positions = [SCNVector3]()
        var dotNodes = [SCNNode]()
        
        var highlightedNode: SCNNode? = nil
        
        for i in 0...textureMap.count - 1 {
            let u = textureMap[i].x
            let v = textureMap[i].y
            
            let pixelColor = self.getPixelColor(x: Int(u), y: Int(v))
            let isHighlight = u == newYorkDot.x && v == newYorkDot.y
            
            if (isHighlight) {
                let dotNode = SCNNode(geometry: highlightGeometry)
                dotNode.name = "NewYorkDot"
                dotNode.position = textureMap[i].position
                positions.append(dotNode.position)
                dotNodes.append(dotNode)
                
                print("myloc \(textureMap[i].position)")
                
                highlightedNode = dotNode
            } else if (pixelColor.red < threshold && pixelColor.green < threshold && pixelColor.blue < threshold) {
                let dotNode = SCNNode(geometry: dotGeometry)
                dotNode.name = "Other"
                dotNode.position = textureMap[i].position
                positions.append(dotNode.position)
                dotNodes.append(dotNode)
            }
        }
        
        DispatchQueue.main.async {
            let dotPositions = positions as NSArray
            let dotIndices = NSArray()
            let source = SCNGeometrySource(vertices: dotPositions as! [SCNVector3])
            let element = SCNGeometryElement(indices: dotIndices as! [Int32], primitiveType: .point)
            
            let pointCloud = SCNGeometry(sources: [source], elements: [element])
            
            let pointCloudNode = SCNNode(geometry: pointCloud)
            for dotNode in dotNodes {
                pointCloudNode.addChildNode(dotNode)
            }
     
            self.sceneView.scene?.rootNode.addChildNode(pointCloudNode)
            
//            DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
//                if let highlightedNode = highlightedNode {
//                    self.alignPointToPositiveZ(for: pointCloudNode, targetPoint: highlightedNode.position)
//                }
//            }
        }
    }
    
    func centerCameraOnDot(dotPosition: SCNVector3) {
        let targetPhi = atan2(dotPosition.x, dotPosition.z)
        let targetTheta = asin(dotPosition.y / dotPosition.length())

        // Convert spherical coordinates back to Cartesian
        let newX = 1 * sin(targetTheta) * sin(targetPhi)
        let newY = 1 * cos(targetTheta)
        let newZ = 1 * sin(targetTheta) * cos(targetPhi)

        let fixedDistance: Float = 6.0
        let newCameraPosition = SCNVector3(newX, newY, newZ).normalized().scaled(to: fixedDistance)

        let moveAction = SCNAction.move(to: newCameraPosition, duration: 0.8)

        // Create additional actions as needed
        //let rotateAction = SCNAction.rotateBy(x: 0, y: 0, z: 0, duration: 0.5)

        // Create an array of actions
        let sequenceAction = SCNAction.sequence([moveAction])

        // Run the sequence action on the cameraNode
        cameraNode.runAction(sequenceAction)
    }

    func alignPointToPositiveZ(for sphereNode: SCNNode, targetPoint: SCNVector3) {
        
        // Compute normalized vector from Earth's center to the target point
        let targetDirection = targetPoint.normalized()
        
        // Compute quaternion rotation
        let up = SCNVector3(0, 0, 1)
        let rotationQuaternion = SCNQuaternion.fromVectorRotate(from: up, to: targetDirection)
        
        sphereNode.orientation = rotationQuaternion
        
    }
    
    typealias MapDot = (position: SCNVector3, x: Int, y: Int)
    
    private func generateTextureMap(dots: Int, sphereRadius: CGFloat) -> [MapDot] {

        let phi = Double.pi * (sqrt(5) - 1)
        var positions = [MapDot]()

        for i in 0..<dots {

            let y = 1.0 - (Double(i) / Double(dots - 1)) * 2.0 // y is 1 to -1
            let radiusY = sqrt(1 - y * y)
            let theta = phi * Double(i) // Golden angle increment
            
            let x = cos(theta) * radiusY
            let z = sin(theta) * radiusY

            let vector = SCNVector3(x: Float(sphereRadius * x),
                                    y: Float(sphereRadius * y),
                                    z: Float(sphereRadius * z))

            let pixel = equirectangularProjection(point: Point3D(x: x, y: y, z: z),
                                                  imageWidth: 2048,
                                                  imageHeight: 1024)

            let position = MapDot(position: vector, x: pixel.u, y: pixel.v)
            positions.append(position)
        }
        return positions
    }
    
    struct Point3D {
        let x: Double
        let y: Double
        let z: Double
    }

    struct Pixel {
        let u: Int
        let v: Int
    }

    func equirectangularProjection(point: Point3D, imageWidth: Int, imageHeight: Int) -> Pixel {
        let theta = asin(point.y)
        let phi = atan2(point.x, point.z)
        
        let u = Double(imageWidth) / (2.0 * .pi) * (phi + .pi)
        let v = Double(imageHeight) / .pi * (.pi / 2.0 - theta)
        
        return Pixel(u: Int(u), v: Int(v))
    }
    
    private func distanceBetweenPoints(x1: Int, y1: Int, x2: Int, y2: Int) -> Double {
        let dx = Double(x2 - x1)
        let dy = Double(y2 - y1)
        return sqrt(dx * dx + dy * dy)
    }
    
    private func closestDotPosition(to coordinate: CLLocationCoordinate2D, in positions: [(position: SCNVector3, x: Int, y: Int)]) -> (x: Int, y: Int) {
        let pixelPositionDouble = getEquirectangularProjectionPosition(for: coordinate)
        let pixelPosition = (x: Int(pixelPositionDouble.x), y: Int(pixelPositionDouble.y))

                
        let nearestDotPosition = positions.min { p1, p2 in
            distanceBetweenPoints(x1: pixelPosition.x, y1: pixelPosition.y, x2: p1.x, y2: p1.y) <
                distanceBetweenPoints(x1: pixelPosition.x, y1: pixelPosition.y, x2: p2.x, y2: p2.y)
        }
        
        return (x: nearestDotPosition?.x ?? 0, y: nearestDotPosition?.y ?? 0)
    }
    
    /// Convert a coordinate to an (x, y) coordinate on the world map image
    private func getEquirectangularProjectionPosition(
        for coordinate: CLLocationCoordinate2D
    ) -> CGPoint {
        let imageHeight = CGFloat(worldMapImage.height)
        let imageWidth = CGFloat(worldMapImage.width)

        // Normalize longitude to [0, 360). Longitude in MapKit is [-180, 180)
        let normalizedLong = coordinate.longitude + 180
        // Calculate x and y positions
        let xPosition = (normalizedLong / 360) * imageWidth
        // Note: Latitude starts from top, hence the `-` sign
        let yPosition = (-(coordinate.latitude - 90) / 180) * imageHeight
        return CGPoint(x: xPosition, y: yPosition)
    }

    private func getPixelColor(x: Int, y: Int) -> (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) {
        let data: UnsafePointer<UInt8> = CFDataGetBytePtr(imgData)
        let pixelInfo: Int = ((worldMapWidth * y) + x) * 4

        let r = CGFloat(data[pixelInfo]) / CGFloat(255.0)
        let g = CGFloat(data[pixelInfo + 1]) / CGFloat(255.0)
        let b = CGFloat(data[pixelInfo + 2]) / CGFloat(255.0)
        let a = CGFloat(data[pixelInfo + 3]) / CGFloat(255.0)

        return (r, g, b, a)
    }
}

private extension Color {
    static var earthColor: Color {
        return Color(red: 0.227, green: 0.133, blue: 0.541)
    }
    
    static var earthGlow: Color {
        Color(red: 0.133, green: 0.0, blue: 0.22)
    }
    
    static var earthReflection: Color {
        Color(red: 0.227, green: 0.133, blue: 0.541)
    }
}

extension SCNVector3 {
    func length() -> Float {
        return sqrtf(x*x + y*y + z*z)
    }

    func normalized() -> SCNVector3 {
        let len = length()
        return SCNVector3(x: x/len, y: y/len, z: z/len)
    }

    func scaled(to length: Float) -> SCNVector3 {
        return SCNVector3(x: x * length, y: y * length, z: z * length)
    }

    func dot(_ v: SCNVector3) -> Float {
        return x * v.x + y * v.y + z * v.z
    }

    func cross(_ v: SCNVector3) -> SCNVector3 {
        return SCNVector3(y * v.z - z * v.y, z * v.x - x * v.z, x * v.y - y * v.x)
    }
}

extension SCNQuaternion {
    static func fromVectorRotate(from start: SCNVector3, to end: SCNVector3) -> SCNQuaternion {
        let c = start.cross(end)
        let d = start.dot(end)
        let s = sqrt((1 + d) * 2)
        let invs = 1 / s

        return SCNQuaternion(x: c.x * invs, y: c.y * invs, z: c.z * invs, w: s * 0.5)
    }
}

typealias GenericControllerRepresentable = UIViewControllerRepresentable

@available(iOS 13.0, *)
private struct GlobeViewControllerRepresentable: GenericControllerRepresentable {
    var particles: SCNParticleSystem? = nil
    //@Binding public var showProf: Bool

    func makeUIViewController(context: Context) -> GlobeViewController {
        let globeController = GlobeViewController(earthRadius: 1.0)//, showProf: $showProf
        updateGlobeController(globeController)
        return globeController
    }
    
    func updateUIViewController(_ uiViewController: GlobeViewController, context: Context) {
        updateGlobeController(uiViewController)
    }
    
    private func updateGlobeController(_ globeController: GlobeViewController) {
        globeController.dotSize = CGFloat(0.005)
              
        globeController.enablesParticles = true
        
        if let particles = particles {
            globeController.particles = particles
        }
    }
}

@available(iOS 13.0, *)
public struct GlobeView: View {
    //@Binding public var showProf: Bool
    
    public var body: some View {
        GlobeViewControllerRepresentable()//showProf: $showProf
    }
}

Latest articles

spot_imgspot_img

Related articles

Leave a reply

Please enter your comment!
Please enter your name here

spot_imgspot_img