West1
West1

Reputation: 1918

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.

Upvotes: 1

Views: 410

Answers (2)

DonMag
DonMag

Reputation: 77423

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.

Upvotes: 1

Andy Jazz
Andy Jazz

Reputation: 58053

SCNScene visualizes only 3D renderable surfaces. So, since an object of UIBezierPath isn't a renderable surface (it's just a 2D line), its own width has no effect in SceneKit's 3D scene.

You need to extrude a 3D line. Here's the solution:

override func viewDidLoad() {
    super.viewDidLoad()
    
    let sceneView = self.view as! SCNView
    sceneView.scene = SCNScene()
    sceneView.allowsCameraControl = true
    sceneView.backgroundColor = .black
    sceneView.scene?.rootNode.addChildNode(createLine(0.03, length: 10.0))
}

func createLine(_ lineWidth: Double, length: Double) -> SCNNode {
            
    let path = UIBezierPath()
    path.move(to: CGPoint(x: length, y: 0))
    path.addLine(to: CGPoint(x: length, y: lineWidth))
    path.addLine(to: CGPoint(x: 0, y: lineWidth))
    path.addLine(to: CGPoint(x: 0, y: 0))
    path.close()
    
    let shape = SCNShape(path: path, extrusionDepth: lineWidth)
    let material = SCNMaterial()
    material.lightingModel = .constant
    material.diffuse.contents = UIColor.systemRed
    shape.materials = [material]
    
    let lineNode = SCNNode(geometry: shape)
    return lineNode
}

enter image description here

Upvotes: 2

Related Questions