Search code examples
swiftscenekituibezierpath

Set lineWidth of UIBezierPath for use in SceneKit


I can't get UIBezierPath's lineWidth property to work when using it in SceneKit. The end product has the minimum lineWidth (it's very thin), whereas I need a thick line.

The path is used to construct an SCNShape, which is then used to construct an SCNNode.

Consider the following code:

let hugePath = UIBezierPath()
        
hugePath.lineWidth = 40.0 //Has no effect

hugePath.move(to: CGPoint(x: previousPathPosition.x, y: previousPathPosition.y))

hugePath.addLine(to: CGPoint(x: block.position.x, y: block.position.y))

let hugeShape = SCNShape(path: hugePath, extrusionDepth: 150.0)
let hugeMaterial = SCNMaterial()
hugeMaterial.diffuse.contents = UIColor.red
hugeShape.materials = [hugeMaterial, hugeMaterial, hugeMaterial, hugeMaterial, hugeMaterial, hugeMaterial]

let hugeNode = SCNNode(geometry: hugeShape)
hugeNode.position.x = 0.0 
hugeNode.position.z = 5.0
hugeNode.position.y = 0.0

scnView.scene?.rootNode.addChildNode(hugeNode)

There are numerous SO questions on how this problem pertains to UIBezierPath and CAShapeLayer, but none that I see on how it pertains to SceneKit. With the CAShapeLayer problem, the solution is apparently to set lineWidth on the actual layer -- NOT the path. But that doesn't seem to apply to the SceneKit situation.

How can I create a path for use in SceneKit that has a functioning lineWidth property?

EDIT: What I'm trying to do is connect a series of points with a solid, 3D path. Thanks to Andy's answer, I think I'm on the right track, but I'm still a bit stuck.

So, here's where I'm at now: Instead of trying to create a line by manually drawing each side of a bunch of 2D rectangles that are then extruded (which is what I believe Andy's answer recommends), I'm trying to take advantage of UIBezierPath's apply(_ transform:) method. So, I'm drawing a single line connecting the points (hugePath), then making a copy of that line (hugePathTopPart), then transforming the copy to the desired "lineWidth", then connecting the two lines to form a single path.

Like this:

//Make a copy of the line:
let hugePathTopPart = hugePath.copy() as? UIBezierPath
//Move the copy upward. This is effectively the "lineWidth":
hugePathTopPart?.apply(CGAffineTransform(translationX: 0.0, y: 40.0))
//Combine the lines to (hopefully) create a single object:
hugePath.append(hugePathTopPart!)

The problem, now, is that I have these two parallel lines with a big gap between them. I need to fill that gap so it's just one solid shape/line.


Solution

  • From Apple's docs: "SceneKit uses a right-handed coordinate system where (by default) the direction of view is along the negative z-axis..."

    enter image description here

    Path geometry starts on the XY plane, and is extruded on the Z-axis.

    So, if we start with a (vertical) "line" path and extrude it:

        let path = UIBezierPath()
        path.move(to: .zero)
        path.addLine(to: .init(x: 0.0, y: 1.0))
        
        // extrude it to create the shape
        let shape = SCNShape(path: path, extrusionDepth: 10.0)
    

    We get this:

    enter image description here

    It has Y and Z dimensions, but no X (width).

    So, instead of a line, let's start with a rectangle - 0.1 width and 1.0 height:

        // rectangle bezier path
        let path = UIBezierPath(rect: CGRect(x: 0.0, y: 0.0, width: 0.10, height: 1.0))
    

    enter image description here

    We see that the path is on the XY plane... if we extrude it:

        // rectangle bezier path
        let path = UIBezierPath(rect: CGRect(x: 0.0, y: 0.0, width: 0.10, height: 1.0))
    
        // extrude it to create the shape
        let shape = SCNShape(path: path, extrusionDepth: 10.0)
    

    We get this:

    enter image description here

    Quick example code:

    class WallViewController: UIViewController {
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            let sceneView = SCNView(frame: self.view.frame)
            self.view.addSubview(sceneView)
            
            sceneView.allowsCameraControl = true
            sceneView.autoenablesDefaultLighting = true
            sceneView.backgroundColor = .black
            
            let scene = SCNScene()
            sceneView.scene = scene
            
            // rectangle bezier path
            let path = UIBezierPath(rect: CGRect(x: 0.0, y: 0.0, width: 0.10, height: 1.0))
            
            // extrude it to create the shape
            let shape = SCNShape(path: path, extrusionDepth: 10.0)
            
            let mat = SCNMaterial()
            mat.diffuse.contents = UIColor(white: 0.75, alpha: 1.0)
            mat.lightingModel = .physicallyBased
            shape.materials = [mat]
            
            // set shape node
            let shapeNode = SCNNode(geometry: shape)
            
            // add it to the scene
            scene.rootNode.addChildNode(shapeNode)
            
            // let's add a camera for the "starting view"
            let camera = SCNCamera()
            let cameraNode = SCNNode()
            cameraNode.camera = camera
            cameraNode.position = SCNVector3(x: 0.5, y: 2.0, z: 7.0)
            scene.rootNode.addChildNode(cameraNode)
            
            let constraint = SCNLookAtConstraint(target: shapeNode)
            constraint.isGimbalLockEnabled = true
            cameraNode.constraints = [constraint]
            
        }
        
    }
    

    Edit - based on clarification in comments...

    OK, so the goal is to take a UIBezierPath such as this:

    enter image description here

    and turn it into a SCNNode object like this:

    enter image description here

    First thing to understand is that drawing a path (such as on a CAShapeLayer) with the layer's .lineWidth = 20 looks like this (the red line is our original path, underneath that we have the same path with strokeColor = lightGray):

    enter image description here

    However, that line width is drawing only - it doesn't change the geometry of the path.

    We can look at copy(strokingWithWidth:lineCap:lineJoin:miterLimit:transform:) (docs) to turn the "outline" into a path:

    // create a CGPath by stroking the UIBezierPath
    let pathRef = path.cgPath.copy(strokingWithWidth: 20.0, lineCap: CGLineCap.butt, lineJoin: CGLineJoin.miter, miterLimit: 10.0)
    

    Here, the underneath layer has fillColor = lightGray and strokeColor = white:

    enter image description here

    Looking at the stroke, though, we quickly notice that the geometry is not really the "outline" ... and, if we try to use the resulting path as a SCNode object it will fail.

    So we need to "Normalize" the path. If we're using iOS 16+, CGPath has a built-in normalize method. For earlier iOS versions, we'd need to find a suitable substitute:

    // create a CGPath by stroking the UIBezierPath
    let pathRef = path.cgPath.copy(strokingWithWidth: 20.0, lineCap: CGLineCap.butt, lineJoin: CGLineJoin.miter, miterLimit: 10.0)
        
    // convert back to a Normalized UIBezierPath
    path = UIBezierPath(cgPath: pathRef.normalized())
        
    

    and now we have a path that looks like this:

    enter image description here

    Here's some example code to play with. Please note: This is Example Code Only!!! I used a fair bit of hard-coded values to get the desired results - particularly with scaling.

    First, a view that let's us draw a path, adding segments with touch-drag. It also displays differently for path-only, lineWidth, stroked and normalized:

    enum PathType: Int {
        case path, lineWidth, stroked, normalized
    }
    
    class MyPathView: UIView {
        
        // closure to report the path has changed
        public var pathChanged: (([CGPoint]) -> ())?
        
        public var pathType: PathType = .lineWidth {
            didSet {
                shapeLayerA.opacity = 1.0
                shapeLayerA.strokeColor = UIColor.white.cgColor
                shapeLayerB.strokeColor = UIColor.red.cgColor
                switch pathType {
                case .path:
                    shapeLayerB.strokeColor = UIColor.white.cgColor
                    shapeLayerA.opacity = 0.0
                case .lineWidth:
                    shapeLayerA.strokeColor = UIColor.lightGray.cgColor
                    shapeLayerA.fillColor = nil
                    shapeLayerA.lineWidth = 20
                default:
                    shapeLayerA.fillColor = UIColor.lightGray.cgColor
                    shapeLayerA.lineWidth = 1
                }
                setNeedsLayout()
            }
        }
        
        private var myPoints: [CGPoint] = []
        private var curTouch: CGPoint = .zero
        
        // shapeLayerA will show either
        //  nothing
        //  path with lineWidth
        //  stroked and filled path from strokingWithWidth
        //  stroked and filled path from Normalized strokingWithWidth
        private let shapeLayerA = CAShapeLayer()
        
        // shapeLayerB will always show the path with lineWidth = 1
        private let shapeLayerB = CAShapeLayer()
    
        public func reset() {
            myPoints = []
            setNeedsLayout()
        }
        override init(frame: CGRect) {
            super.init(frame: frame)
            commonInit()
        }
        required init?(coder: NSCoder) {
            super.init(coder: coder)
            commonInit()
        }
        private func commonInit() {
            backgroundColor = .black
    
            layer.addSublayer(shapeLayerA)
            layer.addSublayer(shapeLayerB)
    
            shapeLayerA.strokeColor = UIColor.white.cgColor
            shapeLayerA.fillColor = nil
            shapeLayerA.lineWidth = 20
            
            shapeLayerB.strokeColor = UIColor.white.cgColor
            shapeLayerB.fillColor = nil
            shapeLayerB.lineWidth = 1
            
            shapeLayerA.opacity = 0.0
        }
        
        override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
            guard let t = touches.first else { return }
            curTouch = t.location(in: self)
            setNeedsLayout()
        }
        override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
            guard let t = touches.first else { return }
            
            // let's keep it inside the view
            var p = t.location(in: self)
            p.x = max(0.0, min(bounds.maxX, p.x))
            p.y = max(0.0, min(bounds.maxY, p.y))
            curTouch = p
    
            setNeedsLayout()
        }
        override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
            guard let t = touches.first else { return }
            
            // let's keep it inside the view
            var p = t.location(in: self)
            p.x = max(0.0, min(bounds.maxX, p.x))
            p.y = max(0.0, min(bounds.maxY, p.y))
            myPoints.append(p)
    
            curTouch = .zero
            
            pathChanged?(myPoints)
            
            setNeedsLayout()
        }
        override func layoutSubviews() {
            super.layoutSubviews()
            
            var path: UIBezierPath!
            
            if myPoints.isEmpty {
                myPoints.append(.init(x: 20.0, y: bounds.midY))
                pathChanged?(myPoints)
            }
            
            path = UIBezierPath()
            
            myPoints.forEach { pt in
                if pt == myPoints.first {
                    path.move(to: pt)
                } else {
                    path.addLine(to: pt)
                }
            }
            if curTouch != .zero {
                path.addLine(to: curTouch)
            }
            
            shapeLayerB.path = path.cgPath
            
            if pathType != .lineWidth {
                // create a CGPath by stroking the path
                let pathRef = path.cgPath.copy(strokingWithWidth: 20.0, lineCap: CGLineCap.butt, lineJoin: CGLineJoin.miter, miterLimit: 10.0)
                if pathType == .stroked {
                    // convert back to a UIBezierPath
                    path = UIBezierPath(cgPath: pathRef)
                } else {
                    // convert back to a Normalized UIBezierPath
                    path = UIBezierPath(cgPath: pathRef.normalized())
                }
            }
    
            shapeLayerA.path = path.cgPath
            
        }
        
    }
    

    Next, a controller to hold the drawing view and a SCNView, as well as a couple options:

    class ExampleViewController: UIViewController {
    
        var pathView: MyPathView!
        
        var sceneView: SCNView!
        var scene: SCNScene!
        
        var cameraNode: SCNNode!
        var shapeNode: SCNNode!
        
        var pathPoints: [CGPoint] = []
        
        override func viewDidLoad() {
            super.viewDidLoad()
            
            view.backgroundColor = UIColor(white: 0.95, alpha: 1.0)
    
            pathView = MyPathView()
            
            sceneView = SCNView(frame: .zero)
            sceneView.allowsCameraControl = true
            sceneView.autoenablesDefaultLighting = true
            sceneView.backgroundColor = .black
            
            scene = SCNScene()
            sceneView.scene = scene
    
            // a couple options
            let resetButton: UIButton = {
                let v = UIButton()
                v.setTitle("Reset", for: [])
                v.setTitleColor(.white, for: .normal)
                v.setTitleColor(.lightGray, for: .highlighted)
                v.backgroundColor = .systemBlue
                v.layer.cornerRadius = 6
                v.addTarget(self, action: #selector(resetTapped(_:)), for: .touchUpInside)
                return v
            }()
            let segCtrl = UISegmentedControl(items: ["path", "lineWidth", "stroked", "normalized"])
            segCtrl.selectedSegmentIndex = 0
            segCtrl.addTarget(self, action: #selector(segChanged(_:)), for: .valueChanged)
            
            let cStack = UIStackView(arrangedSubviews: [resetButton, segCtrl])
            cStack.spacing = 12
            
            [cStack, pathView, sceneView].forEach { v in
                v.translatesAutoresizingMaskIntoConstraints = false
                view.addSubview(v)
            }
    
            let g = view.safeAreaLayoutGuide
            NSLayoutConstraint.activate([
    
                cStack.topAnchor.constraint(equalTo: g.topAnchor, constant: 20.0),
                cStack.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
                cStack.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
    
                resetButton.widthAnchor.constraint(equalToConstant: 160.0),
    
                pathView.topAnchor.constraint(equalTo: cStack.bottomAnchor, constant: 20.0),
                pathView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 40.0),
                pathView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: -40.0),
                pathView.bottomAnchor.constraint(equalTo: sceneView.topAnchor, constant: -40.0),
    
                sceneView.leadingAnchor.constraint(equalTo: g.leadingAnchor, constant: 0.0),
                sceneView.trailingAnchor.constraint(equalTo: g.trailingAnchor, constant: 0.0),
                sceneView.bottomAnchor.constraint(equalTo: g.bottomAnchor, constant: 0.0),
                sceneView.heightAnchor.constraint(equalTo: g.heightAnchor, multiplier: 0.5),
    
            ])
            
            pathView.pathChanged = { [weak self] pts in
                guard let self = self else { return }
                self.pathPoints = []
                // let's normalize the path points by
                //  setting minimum X and Y to zero
                // and, because CALayer uses 0,0 at top left we'll also
                //  invert the Y values
                let minx = pts.min(by: {$0.x < $1.x})!
                let miny = pts.min(by: {$0.y < $1.y})!
                pts.forEach { pt in
                    self.pathPoints.append(.init(x: pt.x - minx.x, y: -(pt.y - miny.y)))
                }
                self.updateScene()
            }
            
            initScene()
        }
        func initScene() {
    
            scene = SCNScene()
            sceneView.scene = scene
            
            // add a RGB axis indicator
            let axis = Origin(radiusRatio: 0.02)
            axis.simdScale = simd_float3(x: 200.0, y: 200.0, z: 200.0)
            scene.rootNode.addChildNode(axis)
    
            // add a camera node
            cameraNode = SCNNode()
            cameraNode.camera = SCNCamera()
            scene.rootNode.addChildNode(cameraNode)
    
            // position it off-center
            cameraNode.position = SCNVector3Make(10, 20, 50)
    
            // tell the camera to look at the center of the axis indicator
            let constraint = SCNLookAtConstraint(target: axis)
            constraint.isGimbalLockEnabled = true
            cameraNode.constraints = [constraint]
            
        }
        
        @objc func resetTapped(_ sender: Any?) {
            pathPoints = []
            // remove previously added shape if it exists
            if let sn = shapeNode {
                sn.removeFromParentNode()
            }
            pathView.reset()
        }
        @objc func segChanged(_ sender: UISegmentedControl) {
            if let pt = PathType(rawValue: sender.selectedSegmentIndex) {
                pathView.pathType = pt
            }
        }
        func updateScene() {
            
            if pathPoints.isEmpty {
                // nothing to generate yet
                return()
            }
    
            // remove previously added shape if it exists
            if let sn = shapeNode {
                sn.removeFromParentNode()
            }
            
            // generate path
            var path = UIBezierPath()
            path.move(to: pathPoints[0])
            for i in 1..<pathPoints.count {
                path.addLine(to: pathPoints[i])
            }
    
            // create a CGPath by stroking the UIBezierPath
            let pathRef = path.cgPath.copy(strokingWithWidth: 20.0, lineCap: CGLineCap.butt, lineJoin: CGLineJoin.miter, miterLimit: 10.0)
            
            // convert back to a Normalized UIBezierPath
            path = UIBezierPath(cgPath: pathRef.normalized())
            
            // this only has an effect if we have arcs in the path
            path.flatness = 0
            
            // our path is based on points, so
            // scale down by 10
            path.apply(CGAffineTransform(scaleX: 0.1, y: 0.1))
    
            // extrude it to create the shape
            let shape = SCNShape(path: path, extrusionDepth: 10.0)
            
            let mat = SCNMaterial()
            mat.diffuse.contents = UIColor.systemYellow
            mat.lightingModel = .physicallyBased
            shape.materials = [mat]
            
            // set shape node
            shapeNode = SCNNode(geometry: shape)
    
            // add it to the scene
            scene.rootNode.addChildNode(shapeNode)
    
            // move the shape so its boundingBox is centered at 0,0,0
            let box = shapeNode.boundingBox
            let xx = box.max.x - box.min.x
            let yy = box.min.y - box.max.y
            shapeNode.position = SCNVector3(x: -xx * 0.5, y: -yy * 0.5, z: 0.0)
            
        }
        
    }
    

    And, a few "helper" classes / extensions used in the above code:

    // slightly modified version from
    //  https://gist.github.com/cenkbilgen/ba5da0b80f10dc69c10ee59d4ccbbda6
    class Origin: SCNNode {
        
        // see: https://developer.apple.com/documentation/arkit/arsessionconfiguration/worldalignment/gravityandheading
        // if ar session configured with gravity and heading, then +x is east, +y is up, +z is south
        
        private enum Axis {
            case x, y, z
            
            //var normal: float3 {
            var normal: SIMD3<Float> {
                switch self {
                case .x: return simd_float3(1, 0, 0)
                case .y: return simd_float3(0, 1, 0)
                case .z: return simd_float3(0, 0, 1)
                }
            }
        }
        
        // TODO: Set pivot to origin and redo tranforms, it'll make it easier to place additional nodes
        
        init(length: CGFloat = 0.1, radiusRatio ratio: CGFloat = 0.04, color: (x: UIColor, y: UIColor, z: UIColor, origin: UIColor) = (.systemRed, .systemGreen, .systemBlue, .cyan)) {
            
            // x-axis
            let xAxis1 = SCNCylinder(radius: length*ratio, height: length)
            xAxis1.firstMaterial?.diffuse.contents = color.x.darker()
            let xAxisNode1 = SCNNode(geometry: xAxis1)
            xAxisNode1.simdWorldOrientation = simd_quatf.init(angle: .pi/2, axis: Axis.z.normal)
            xAxisNode1.simdWorldPosition = simd_float1(length) * 0.5 * Axis.x.normal
    
            let xAxis2 = SCNCylinder(radius: length*ratio, height: length)
            xAxis2.firstMaterial?.diffuse.contents = color.x.lighter()
            let xAxisNode2 = SCNNode(geometry: xAxis2)
            xAxisNode2.simdWorldOrientation = simd_quatf.init(angle: .pi/2, axis: Axis.z.normal)
            xAxisNode2.simdWorldPosition = simd_float1(length) * -0.5 * Axis.x.normal
    
            // y-axis
            let yAxis1 = SCNCylinder(radius: length*ratio, height: length)
            yAxis1.firstMaterial?.diffuse.contents = color.y.darker()
            let yAxisNode1 = SCNNode(geometry: yAxis1)
            yAxisNode1.simdWorldPosition = simd_float1(length) * 0.5 * Axis.y.normal // just shift
    
            // y-axis
            let yAxis2 = SCNCylinder(radius: length*ratio, height: length)
            yAxis2.firstMaterial?.diffuse.contents = color.y.lighter()
            let yAxisNode2 = SCNNode(geometry: yAxis2)
            yAxisNode2.simdWorldPosition = simd_float1(length) * -0.5 * Axis.y.normal // just shift
    
            // z-axis
            let zAxis1 = SCNCylinder(radius: length*ratio, height: length)
            zAxis1.firstMaterial?.diffuse.contents = color.z.darker()
            let zAxisNode1 = SCNNode(geometry: zAxis1)
            zAxisNode1.simdWorldOrientation = simd_quatf(angle: -.pi/2, axis: Axis.x.normal)
            zAxisNode1.simdWorldPosition = simd_float1(length) * 0.5 * Axis.z.normal
            
            // z-axis
            let zAxis2 = SCNCylinder(radius: length*ratio, height: length)
            zAxis2.firstMaterial?.diffuse.contents = color.z.lighter()
            let zAxisNode2 = SCNNode(geometry: zAxis2)
            zAxisNode2.simdWorldOrientation = simd_quatf(angle: -.pi/2, axis: Axis.x.normal)
            zAxisNode2.simdWorldPosition = simd_float1(length) * -0.5 * Axis.z.normal
            
            // dot at origin
            let origin = SCNSphere(radius: length*ratio)
            origin.firstMaterial?.diffuse.contents = color.origin
            let originNode = SCNNode(geometry: origin)
            
            super.init()
            
            self.addChildNode(originNode)
            self.addChildNode(xAxisNode1)
            self.addChildNode(xAxisNode2)
            self.addChildNode(yAxisNode1)
            self.addChildNode(yAxisNode2)
            self.addChildNode(zAxisNode1)
            self.addChildNode(zAxisNode2)
    
        }
        
        required init?(coder aDecoder: NSCoder) {
            super.init(coder: aDecoder)
        }
        
    }
    
    // typical UIColor extension for lighter / darker
    extension UIColor {
        
        func lighter(by percentage: CGFloat = 30.0) -> UIColor? {
            return self.adjust(by: abs(percentage) )
        }
        
        func darker(by percentage: CGFloat = 30.0) -> UIColor? {
            return self.adjust(by: -1 * abs(percentage) )
        }
        
        func adjust(by percentage: CGFloat = 30.0) -> UIColor? {
            var red: CGFloat = 0, green: CGFloat = 0, blue: CGFloat = 0, alpha: CGFloat = 0
            if self.getRed(&red, green: &green, blue: &blue, alpha: &alpha) {
                return UIColor(red: min(red + percentage/100, 1.0),
                               green: min(green + percentage/100, 1.0),
                               blue: min(blue + percentage/100, 1.0),
                               alpha: alpha)
            } else {
                return nil
            }
        }
        
    }
    

    Running that example code gives us this to play with:

    enter image description here

    Again, Example Code Only!!! - and will work best on an iPad.