Reputation: 1918
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
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..."
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:
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))
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:
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:
and turn it into a SCNNode
object like this:
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):
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:
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:
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:
Again, Example Code Only!!! - and will work best on an iPad.
Upvotes: 1
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
}
Upvotes: 2