|
import SceneKit |
|
import UIKit |
|
|
|
final public class SCNOrbitControl { |
|
|
|
private weak var camera: SCNNode? |
|
|
|
// Target point to orbit around |
|
public var target: SIMD3<Float> = .zero |
|
public weak var targetNode: SCNNode? { |
|
didSet { |
|
setCameraForNode() |
|
} |
|
} |
|
|
|
public var rotationSpeed: Float = 0.005 |
|
public var zoomSpeed: Float = 0.8 |
|
public var minDistance: Float = 0.1 |
|
public var maxDistance: Float = Float.infinity |
|
|
|
public lazy var tapGesture = UIPanGestureRecognizer(target: self, action: #selector( handlePan)) |
|
public lazy var pinchGesture = UIPinchGestureRecognizer(target: self, action: #selector(handlePinch)) |
|
|
|
private var touchOld: CGPoint = .zero |
|
private var scaleOld: Float = 0 |
|
|
|
// MARK: - Initialization |
|
|
|
init(camera: SCNNode) { |
|
self.camera = camera |
|
} |
|
|
|
public func addGestures(sceneView: UIView) { |
|
sceneView.addGestureRecognizer(tapGesture) |
|
sceneView.addGestureRecognizer(pinchGesture) |
|
} |
|
|
|
// MARK: - Touch Handling |
|
|
|
@objc |
|
public func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) { |
|
guard let view = gestureRecognizer.view else { return } |
|
let tapPosition = gestureRecognizer.location(in: view) |
|
|
|
switch gestureRecognizer.state { |
|
case .began: |
|
touchOld = tapPosition |
|
case .changed: |
|
let movementX = tapPosition.x - touchOld.x; |
|
let movementY = tapPosition.y - touchOld.y; |
|
let delta = SIMD3<Float>(-Float(movementX), -Float(movementY), 0) |
|
rotate(delta: delta) |
|
touchOld = tapPosition |
|
case .ended: |
|
touchOld = .zero |
|
default: |
|
break |
|
} |
|
} |
|
|
|
|
|
@objc |
|
public func handlePinch(_ gestureRecognizer: UIPinchGestureRecognizer) { |
|
let scale = Float(gestureRecognizer.scale) |
|
switch gestureRecognizer.state { |
|
case .began: |
|
scaleOld = scale |
|
case .changed: |
|
zoom(scale: Float(scale)) |
|
scaleOld = scale |
|
case .ended, .cancelled: |
|
scaleOld = 0 |
|
default: |
|
break |
|
} |
|
} |
|
|
|
private func rotate(delta: SIMD3<Float>) { |
|
guard let camera else { return } |
|
let vector = camera.simdPosition - target |
|
var spherical = Spherical(vector) |
|
|
|
spherical.theta += delta.x * rotationSpeed |
|
spherical.phi += delta.y * rotationSpeed |
|
|
|
spherical.makeSafe() |
|
|
|
let newPosition = spherical.toVector3() |
|
|
|
camera.simdPosition = target + newPosition |
|
camera.simdLook(at: target, up: [0, 1, 0], localFront: [0, 0, -1]) |
|
} |
|
|
|
private func zoom(scale: Float) { |
|
guard let camera else { return } |
|
|
|
let currentDistance = simd_distance(camera.simdPosition, target) |
|
let delta = (scale - scaleOld) * zoomSpeed |
|
var newDistance = currentDistance * (1.0 - delta) |
|
newDistance = min(max(newDistance, minDistance), maxDistance) |
|
let direction = simd_normalize(camera.simdPosition - target) |
|
camera.simdPosition = target + (direction * newDistance) |
|
} |
|
|
|
private func setCameraForNode() { |
|
guard let camera, let targetNode else { return } |
|
let sphere = targetNode.boundingSphere |
|
let centerLocal: SIMD3 = [sphere.center.x, sphere.center.y, sphere.center.z] |
|
let centerWorld = targetNode.simdConvertPosition(centerLocal, to: nil) |
|
let radiusWorld = simd_length(targetNode.simdConvertVector([sphere.radius, 0, 0], to: nil)) |
|
|
|
target = centerWorld |
|
minDistance = radiusWorld * 1.8 |
|
maxDistance = radiusWorld * 5 |
|
|
|
let degToRad = Float.pi / 180 |
|
let spherical = Spherical( |
|
radius: minDistance, |
|
theta: 1 * degToRad, |
|
phi: 60 * degToRad |
|
) |
|
|
|
camera.simdPosition = spherical.toVector3() |
|
camera.simdLook(at: target, up: [0, 1, 0], localFront: [0, 0, -1]) |
|
} |
|
} |
|
|
|
private struct Spherical { |
|
let radius: Float |
|
// azimuthal angle in radians |
|
var theta: Float = 0.0 |
|
// polar angle in radians (vertical) |
|
var phi: Float = 0.0 |
|
|
|
init(radius: Float, theta: Float, phi: Float) { |
|
self.radius = radius |
|
self.theta = theta |
|
self.phi = phi |
|
} |
|
|
|
init(_ vector: SIMD3<Float>) { |
|
radius = sqrt( |
|
vector.x * vector.x + |
|
vector.y * vector.y + |
|
vector.z * vector.z |
|
) |
|
|
|
if radius == 0 { |
|
theta = 0 |
|
phi = 0 |
|
} else { |
|
theta = atan2(vector.x, vector.z) |
|
phi = acos(max(-1, min(1, vector.y / radius))) |
|
} |
|
} |
|
|
|
/// Restricts the polar angle [page:.phi phi] to be between `0.000001` and pi - `0.000001`. |
|
mutating func makeSafe() { |
|
let EPS: Float = 0.000001 |
|
phi = max(EPS, min(Float.pi - EPS, phi)) |
|
} |
|
|
|
func toVector3() -> SIMD3<Float> { |
|
let sinPhiRadius = sin(phi) * radius |
|
return [ |
|
sinPhiRadius * sin(theta), |
|
cos( phi ) * radius, |
|
sinPhiRadius * cos( theta ) |
|
] |
|
} |
|
} |