'How do I make a hexagon with 6 triangular SCNNodes?

I'm trying to make a hexagon grid with triangles without altering any pivot points, but I can't seem to position the triangles correctly to make single hexagon. I'm creating SCNNodes with UIBezierPaths to form triangles and then rotating the bezier paths. This seems to work fine UNTIL I try to use a parametric equation to position the triangles around a circle to form the hexagon, then they don't end up in the correct position. Can you help me spot where I'm doing wrong here?

class TrianglePlane: SCNNode {

    var size: CGFloat = 0.1
    var coords: SCNVector3 = SCNVector3Zero
    var innerCoords: Int = 0

    init(coords: SCNVector3, innerCoords: Int, identifier: Int) {
        super.init()

        self.coords = coords
        self.innerCoords = innerCoords
        setup()
    }

    init(identifier: Int) {
        super.init()
//        super.init(identifier: identifier)
        setup()
    }

    required init?(coder aDecoder: NSCoder) { 
        fatalError("init(coder:) has not been implemented") 
    }

    func setup() {
        let myPath = path()
        let geo = SCNShape(path: myPath, extrusionDepth: 0)
        geo.firstMaterial?.diffuse.contents = UIColor.red
        geo.firstMaterial?.blendMode = .multiply
        self.geometry = geo
    }

    func path() -> UIBezierPath {

        let max: CGFloat = self.size
        let min: CGFloat = 0

        let bPath = UIBezierPath()
        bPath.move(to: .zero)
        bPath.addLine(to: CGPoint(x: max / 2, 
                                  y: UIBezierPath.middlePeak(height: max)))
        bPath.addLine(to: CGPoint(x: max, y: min))
        bPath.close()
        return bPath
    }
}

extension TrianglePlane {

    static func generateHexagon() -> [TrianglePlane] {

        var myArr: [TrianglePlane] = []

        let colors = [UIColor.red, UIColor.green, 
                      UIColor.yellow, UIColor.systemTeal, 
                      UIColor.cyan, UIColor.magenta]


        for i in 0 ..< 6  {

            let tri = TrianglePlane(identifier: 0)
            tri.geometry?.firstMaterial?.diffuse.contents = colors[i]
            tri.position = SCNVector3( -0.05, 0, -0.5)

//          Rotate bezier path
            let angleInDegrees = (Float(i) + 1) * 180.0
            print(angleInDegrees)
            let angle = CGFloat(deg2rad(angleInDegrees))
            let geo = tri.geometry as! SCNShape
            let path = geo.path!
            path.rotateAroundCenter(angle: angle)
            geo.path = path

//          Position triangle in hexagon
            let radius = Float(tri.size)/2
            let deg: Float = Float(i) * 60
            let radians = deg2rad(-deg)

            let x1 = tri.position.x + radius * cos(radians)
            let y1 = tri.position.y + radius * sin(radians)
            tri.position.x = x1
            tri.position.y = y1

            myArr.append(tri)
        }

        return myArr
    }

    static func deg2rad(_ number: Float) -> Float {
        return number * Float.pi / 180
    }
}

extension UIBezierPath {

    func rotateAroundCenter(angle: CGFloat) {

        let center = self.bounds.center
        var transform = CGAffineTransform.identity
        transform = transform.translatedBy(x: center.x, y: center.y)
        transform = transform.rotated(by: angle)
        transform = transform.translatedBy(x: -center.x, y: -center.y)
        self.apply(transform)
    }

    static func middlePeak(height: CGFloat) -> CGFloat {
        return sqrt(3.0) / 2 * height
    }
}

extension CGRect {
    var center : CGPoint {
        return CGPoint(x:self.midX, y:self.midY)
    }
}

What it currently looks like:

enter image description here

What it SHOULD look like:

enter image description here



Solution 1:[1]

There are a few problems with the code as it stands. Firstly, as pointed out in the comments, the parametric equation for the translations needs to be rotated by 90 degrees:

        let deg: Float = (Float(i) * 60) - 90.0

The next issue is that the centre of the bounding box of the triangle and the centroid of the triangle are not the same point. This is important because the parametric equation calculates where the centroids of the triangles must be located, not the centres of their bounding boxes. So we're going to need a way to calculate the centroid. This can be done by adding the following extension method to TrianglePlane:

extension TrianglePlane {
    /// Calculates the centroid of the triangle
    func centroid() -> CGPoint
    {
        let max: CGFloat = self.size
        let min: CGFloat = 0
        let peak = UIBezierPath.middlePeak(height: max)
        let xAvg = (min + max / CGFloat(2.0) + max) / CGFloat(3.0)
        let yAvg = (min + peak + min) / CGFloat(3.0)
        return CGPoint(x: xAvg,  y: yAvg)
    }
}

This allows the correct radius for the parametric equation to be calculated:

        let height = Float(UIBezierPath.middlePeak(height: tri.size))
        let centroid = tri.centroid()
        let radius = height - Float(centroid.y)

The final correction is to calculate the offset between the origin of the triangle and the centroid. This correction depends on whether the triangle has been flipped by the rotation or not:

        let x1 =  radius * cos(radians)
        let y1 =  radius * sin(radians)
        let dx = Float(-centroid.x)
        let dy = (i % 2 == 0) ? Float(centroid.y) - height  : Float(-centroid.y)
        tri.position.x = x1 + dx
        tri.position.y = y1 + dy

Putting all this together gives the desired result.

enter image description here

Full working ViewController can be found int this gist

Note the code can be greatly simplified by making the origin of the triangle be the centroid.

Solution 2:[2]

I created two versions – SceneKit and RealityKit.


SceneKit (macOS version)

The simplest way to compose a hexagon is to use six non-uniformly scaled SCNPyramids (flat) with their shifted pivot points. Each "triangle" must be rotated in 60 degree increments (.pi/3).

import SceneKit

class ViewController: NSViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        let sceneView = self.view as! SCNView
        let scene = SCNScene()
        sceneView.scene = scene
        sceneView.allowsCameraControl = true
        sceneView.backgroundColor = NSColor.white

        let cameraNode = SCNNode()
        cameraNode.camera = SCNCamera()
        scene.rootNode.addChildNode(cameraNode)
        cameraNode.position = SCNVector3(x: 0, y: 0, z: 15)
        
        for i in 1...6 {

            let triangleNode = SCNNode(geometry: SCNPyramid(width: 1.15,
                                                           height: 1,
                                                           length: 1))

            // the depth of the pyramid is almost zero
            triangleNode.scale = SCNVector3(5, 5, 0.001)

            // move a pivot point from pyramid its base to upper vertex
            triangleNode.simdPivot.columns.3.y = 1     

            triangleNode.geometry?.firstMaterial?.diffuse.contents = NSColor(
                                                 calibratedHue: CGFloat(i)/6,
                                                    saturation: 1.0, 
                                                    brightness: 1.0, 
                                                         alpha: 1.0)

            triangleNode.rotation = SCNVector4(0, 0, 1, 
                                              -CGFloat.pi/3 * CGFloat(i))

            scene.rootNode.addChildNode(triangleNode)
        }
    }
}

enter image description here


RealityKit (iOS version)

In this project I generated a triangle with the help of MeshDescriptor and copied it 5 more times.

import UIKit
import RealityKit

class ViewController: UIViewController {

    @IBOutlet var arView: ARView!
    let anchor = AnchorEntity()
    let camera = PointOfView()
    let indices: [UInt32] = [0, 1, 2]

    override func viewDidLoad() {
        super.viewDidLoad()
        
        self.arView.environment.background = .color(.black)
        self.arView.cameraMode = .nonAR
        self.camera.position.z = 9

        let positions: [simd_float3] = [[ 0.00, 0.00, 0.00],
                                        [ 0.52, 0.90, 0.00],
                                        [-0.52, 0.90, 0.00]]
         
        var descriptor = MeshDescriptor(name: "Hexagon's side")
        descriptor.materials = .perFace(self.indices)
        descriptor.primitives = .triangles(self.indices)
        descriptor.positions = MeshBuffers.Positions(positions[0...2])
        
        var material = UnlitMaterial()
        let mesh: MeshResource = try! .generate(from: [descriptor])
        
        let colors: [UIColor] = [.systemRed, .systemGreen, .yellow,
                                 .systemTeal, .cyan, .magenta]
        
        for i in 0...5 {

            material.color = .init(tint: colors[i], texture: nil)
            let triangleModel = ModelEntity(mesh: mesh, 
                                       materials: [material])

            let trianglePivot = Entity()    // made to control pivot point
            trianglePivot.addChild(triangleModel)
            
            trianglePivot.orientation = simd_quatf(angle: -.pi/3 * Float(i),
                                                    axis: [0,0,1])
            self.anchor.addChild(trianglePivot)
        }
        
        self.anchor.addChild(self.camera)
        self.arView.scene.anchors.append(self.anchor)
    }
}

enter image description here

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1
Solution 2