How to rotate and lock the rotation of a SCNNode by degrees? - swift

I need to rotate a model that can be freely rotated, to exact degrees, regardless of how many times it's been rotated.
I have a UIPanGestureRecognizer that is rotating freely a 3D model around the Y axis. However I'm struggling to get it to lock to a integer degree when panning is stopped, and I'm struggling with being able to know it's rotation in degrees from 0-359.
let translation = recognizer.translation(in: self.view)
var newAngleY = Double(translation.x) * (Double.pi) / 180.0
newAngleY += self.currentAngle
self.shipNode?.eulerAngles.y = Float(newAngleY)
if (recognizer.state == .ended)
{
self.currentAngle = newAngleY
}
It rotates freely, but all attempts for locking to the closest exact degree, and being able to 'know' it's rotational degree in a value from 0-359.
I know that:
let degrees = newAngleY * ( 180 / Double.pi)
And I know that if degrees > 360 then -= 360 (pseudo code)
However, whilst the UIPanGestureRecognizer is doing it's thing, these checks seem to fail and I don't know why. Is it because when it's still being panned, you can't edit the private properties of the ViewController?

You can edit the value while the gesture is occurring.
Quite a few options, so this seems the simplest to start with:
You could try only applying euler when the state changes AND only when .x > .x * (some value, such as 1.1). This would provide a more "snap to" kind of approach, something like:
var currentLocation = CGPoint.zero
var beginLocation = CGPoint.zero
#objc func handlePan(recognizer: UIPanGestureRecognizer) {
currentLocation = recognizer.location(in: gameScene)
var newAngleY = Double(translation.x) * (Double.pi) / 180.0
newAngleY += self.currentAngle
switch recognizer.state
{
case UIGestureRecognizer.State.began: break
case UIGestureRecognizer.State.changed:
if(currentLocation.x > beginLocation.x * 1.1)
{
gNodes.bnode.eulerAngles.y = Float(newAngleY)
beginLocation.x = currentLocation.x
}
if(currentLocation.x < beginLocation.x * 0.9) { .etc. }
break
case UIGestureRecognizer.State.ended:
gNodes.bnode.eulerAngles.y = Float(newAngleY)
break
}
}
Then you could switch to an SCNAction (changing your math) to give more control, such as
let vAction = SCNAction.rotateTo(x: 0, y: vAmount, z: 0, duration: 0)
bnode.runAction(vAction)

Related

how to plot forward/backward tilt on y axis?

I have a line (UIView with width = screen width and height = 2), I need to move this line up and down the screen with forward tilt and backward tilt. I know I need to use Gyroscope, how can I achieve this using MotionManager()?
var motionManager = CMMotionManager()
private func getGyroUpdates() {
if motionManager.isDeviceMotionAvailable == true {
motionManager.deviceMotionUpdateInterval = 0.1
let queue = OperationQueue()
motionManager.startDeviceMotionUpdates(to: queue, withHandler: { [weak self] motion, error in
// Get the attitude of the device
guard let motion = motion else { return }
let pitch = Double(round(motion.attitude.pitch.rad2deg()))
let length = sqrt(motion.gravity.x * motion.gravity.x + motion.gravity.y * motion.gravity.y + motion.gravity.z * motion.gravity.z)
// how to i get the value to be plotted in Y? do i use gravity? or pitch ?
DispatchQueue.main.async {
// frontBackMovement is the line view here
self?.frontBackMovement.transform = CGAffineTransform(translationX: 0, y: "what should be the value here??")
}
})
print("Device motion started")
}else {
print("Device motion unavailable")
}
}
line needs to move in the frame which is a uiview, I need to get value to put in the place of Y to put it in CGAffineTransform. so basically how do I map value I get from Motion object to plot it in Y. I tried the radian value I get from attitude.pitch but how do I convert that into Y? If I use gravity value how do I use it?
Thank you very much for your help in advance.
Depending on the effect you are trying to achieve you would want to get the pitch angle from motion.attitude.pitch. Then you need to calculate the y offset based on how far you want the line to move relative to the pitch angle.
Let's say you want the line to move 100 points up or down as the device is tilted between -90º and 90º.
You had the angle:
let pitch = Double(round(motion.attitude.pitch.rad2deg()))
So now calculate the distance:
let maxDistance = 100.0
let currentDistance = pitch / 90.0 * maxDistance
where maxDistance is the furthest you want the line to move. You probably need some extra checks to ensure pitch is kept between -90 and 90.
A much simpler approach than using CMMotionManager is to use UIInterpolatingMotionEffect. First setup your line to be in the center of its parent view. Then use the following code:
let maxDistance: Float = 100 // how far do you want the line to move
let eff = UIInterpolatingMotionEffect(keyPath: "center.y", type: .tiltAlongVerticalAxis)
eff.maximumRelativeValue = maxDistance
eff.minimumRelativeValue = -maxDistance
lineView.addMotionEffect(eff)
where maxDistance is the max distance you want the line to move as the device is tilted. In your case this sounds like it should be half the height of the line's parent view.

How can I change the touch offset of a sprite in SpriteKit?

When I touch the player mallet (Air Hockey), I want to make it so the mallet moves slightly above the touch. This way the mallet will be more visible in the game. I have found some solutions but am having a hard time implementing properly in my function.
Here is a sample of my touchesMoved() function:
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?)
{
bottomTouchIsActive = true
var releventTouch:UITouch!
//convert set to known type
let touchSet = touches
//get array of touches so we can loop through them
let orderedTouches = Array(touchSet)
for touch in orderedTouches
{
//if we've not yet found a relevent touch
if releventTouch == nil
{
//look for a touch that is in the activeArea (Avoid touches by opponent)
if activeArea.contains(touch.location(in: parent!))
{
isUserInteractionEnabled = true
releventTouch = touch
}
else
{
releventTouch = nil
}
}
}
if (releventTouch != nil)
{
//get touch position and relocate player
let location = releventTouch!.location(in: parent!)
position = location
//find old location and use pythagoras to determine length between both points
let oldLocation = releventTouch!.previousLocation(in: parent!)
let xOffset = location.x - oldLocation.x
let yOffset = location.y - oldLocation.y
let vectorLength = sqrt(xOffset * xOffset + yOffset * yOffset)
//get eleapsed and use to calculate speed6A
if lastTouchTimeStamp != nil
{
let seconds = releventTouch.timestamp - lastTouchTimeStamp!
let velocity = 0.01 * Double(vectorLength) / seconds
//to calculate the vector, the velcity needs to be converted to a CGFloat
let velocityCGFloat = CGFloat(velocity)
//calculate the impulse
let directionVector = CGVector(dx: velocityCGFloat * xOffset / vectorLength, dy: velocityCGFloat * yOffset / vectorLength)
//pass the vector to the scene (so it can apply an impulse to the puck)
delegate?.bottomForce(directionVector, fromBottomPlayer: self)
delegate?.bottomTouchIsActive(bottomTouchIsActive, fromBottomPlayer: self)
}
//update latest touch time for next calculation
lastTouchTimeStamp = releventTouch.timestamp
}
}
Is the position var, which you take from the touch location, used to set the position of the mallet? If it is, then if you want the mallet above the touch, why not do something like position.y += 50 immediately after position = location to move it up by 50 points?
Alternatively, you might find it more logical to set the mallet's anchorPoint property (https://developer.apple.com/documentation/spritekit/skspritenode/1519877-anchorpoint and https://developer.apple.com/documentation/spritekit/skspritenode/using_the_anchor_point_to_move_a_sprite) to be somewhere other than the default poisition (the centre of the sprite) e.g. the point that corresponds to the part of the handle of the mallet where one would normally hold it.

ARKit cannot rotate a SCNNode correctly on a vertical plane

I want to rotate a SCNNode, which is a painting image.
I can rotate it on the floor, but I cannot rotate it correctly on wall.
(I am using UIRotationGestureRecognizer)
...
func addPainting(_ hitResult: ARHitTestResult, _ grid: Grid) {
...
// Add the painting
let newPaintingNode = SCNNode(geometry: planeGeometry)
newPaintingNode.transform = SCNMatrix4(hitResult.anchor!.transform)
newPaintingNode.eulerAngles = SCNVector3(newPaintingNode.eulerAngles.x + (-Float.pi / 2), newPaintingNode.eulerAngles.y, newPaintingNode.eulerAngles.z)
newPaintingNode.position = SCNVector3(hitResult.worldTransform.columns.3.x, hitResult.worldTransform.columns.3.y, hitResult.worldTransform.columns.3.z)
self.paintingNode = newPaintingNode
self.currentAngleY = newPaintingNode.eulerAngles.y
self.currentAngleZ = newPaintingNode.eulerAngles.z
augmentedRealityView.scene.rootNode.addChildNode(self.paintingNode!)
grid.removeFromParentNode()
}
...
#objc func rotateNode(_ gesture: UIRotationGestureRecognizer){
if let currentNode = self.paintingNode {
//1. Get The Current Rotation From The Gesture
let rotation = Float(gesture.rotation)
if self.paintingAlignment == "horizontal" {
log.verbose("rotate horizontal!")
//2. If The Gesture State Has Changed Set The Nodes EulerAngles.y
if gesture.state == .changed {
currentNode.eulerAngles.y = currentAngleY + rotation
}
//3. If The Gesture Has Ended Store The Last Angle Of The Cube
if(gesture.state == .ended) {
currentAngleY = currentNode.eulerAngles.y
}
} else if self.paintingAlignment == "vertical" {
log.verbose("rotate vertical!")
//2. If The Gesture State Has Changed Set The Nodes EulerAngles.z
if gesture.state == .changed {
currentNode.eulerAngles.z = currentAngleZ + rotation
}
//3. If The Gesture Has Ended Store The Last Angle Of The Cube
if(gesture.state == .ended) {
currentAngleZ = currentNode.eulerAngles.z
}
}
}
}
Does anyone know how can I rotate it correctly on wall? Thank you!
You're using eulerAngles instance property in your code:
var eulerAngles: SCNVector3 { get set }
According to Apple documentation:
SceneKit applies eulerAngles rotations relative to the node’s pivot property in the reverse order of the components: first roll (Z), then yaw (Y), then pitch (X).
...but three-component rotation can lead to Gimbal Lock.
Gimbal lock is the loss of one degree of freedom in a three-dimensional, three-gimbal mechanism that occurs when the axes of two of the three gimbals are driven into a parallel configuration, "locking" the system into rotation in a degenerate two-dimensional space.
So you need to use a four-component rotation property:
var rotation: SCNVector4 { get set }
The four-component rotation vector specifies the direction of the rotation axis in the first three components (XYZ) and the angle of rotation, expressed in radians, in the fourth (W).
currentNode.rotation = SCNVector4(x: 1,
y: 0,
z: 0,
w: -Float.pi / 2)
If you want to know more about SCNVector4 structure and four-component rotation (and its W component expressed in radians) look at THIS POST and THIS POST.
P.S.
In RealityKit framework instead of SCNVector4 structure you need to use SIMD4<Float> generic structure or simd_float4 type alias.
var rotation: simd_float4 { get set }
I tried SCNNode eulerAngles and SCNNode rotation and I have no luck...
I guess that is because I invoked SCNNode.transform() when I add the painting. When later I modify SCNNode.eulerAngles and invoke SCNNode.rotation(), they may have influence on the first transform.
I finally get it working using SCNMatrix4Rotate, not using eulerAngles and rotation.
func addPainting(_ hitResult: ARHitTestResult, _ grid: Grid) {
...
let newPaintingNode = SCNNode(geometry: planeGeometry)
newPaintingNode.transform = SCNMatrix4(hitResult.anchor!.transform)
newPaintingNode.transform = SCNMatrix4Rotate(newPaintingNode.transform, -Float.pi / 2.0, 1.0, 0.0, 0.0)
newPaintingNode.position = SCNVector3(hitResult.worldTransform.columns.3.x, hitResult.worldTransform.columns.3.y, hitResult.worldTransform.columns.3.z)
self.paintingNode = newPaintingNode
...
}
...
#objc func rotateNode(_ gesture: UIRotationGestureRecognizer){
if let _ = self.paintingNode {
// Get The Current Rotation From The Gesture
let rotation = Float(gesture.rotation)
if gesture.state == .began {
self.rotationZ = rotation
}
if gesture.state == .changed {
let diff = rotation - self.rotationZ
self.paintingNode?.transform = SCNMatrix4Rotate(self.paintingNode!.transform, diff, 0.0, 0.0, 1.0)
self.rotationZ = rotation
}
}
}
And the above code works on both vertical and horizontal plane.

Apply multiple rotations to ARKit SCNNode

I'm working with an AR app that uses Apple's Focus Square to guide user interaction on both vertical and horizontal surfaces. However, it's only designed to work on horizontal surfaces. I have modified the method that updates the square's rotation to account for vertical surfaces as well. However, using the method that they used only one rotation can be performed at a time, whereas in order to have the square positioned properly on a horizontal surface I need to have two rotations performed (one to apply the original yaw correction, and one to pitch the square vertically).
The relevant code is below, but the specific changes I made are only at the beginning and end.
// MARK: - Appearence
func update(for position: float3, planeAnchor: ARPlaneAnchor?, camera: ARCamera?) {
lastPosition = position
let thisAlignment = planeAnchor?.alignment ?? .horizontal
if thisAlignment != lastAlignment {
lastAlignment = thisAlignment
print("alignmentChanged")
recentFocusSquarePositions = []
}
if let anchor = planeAnchor {
close(flash: !anchorsOfVisitedPlanes.contains(anchor))
lastPositionOnPlane = position
anchorsOfVisitedPlanes.insert(anchor)
} else {
open()
}
updateTransform(for: position, alignment: thisAlignment, camera: camera)
}
private func updateTransform(for position: float3, alignment: ARPlaneAnchor.Alignment, camera: ARCamera?) {
// add to list of recent positions
recentFocusSquarePositions.append(position)
// remove anything older than the last 8
recentFocusSquarePositions.keepLast(8)
// move to average of recent positions to avoid jitter
if let average = recentFocusSquarePositions.average {
self.simdPosition = average
self.setUniformScale(scaleBasedOnDistance(camera: camera))
}
// Correct y rotation of camera square
if let camera = camera {
let tilt = abs(camera.eulerAngles.x)
let threshold1: Float = .pi / 2 * 0.65
let threshold2: Float = .pi / 2 * 0.75
let yaw = atan2f(camera.transform.columns.0.x, camera.transform.columns.1.x)
var angle: Float = 0
switch tilt {
case 0..<threshold1:
angle = camera.eulerAngles.y
case threshold1..<threshold2:
let relativeInRange = abs((tilt - threshold1) / (threshold2 - threshold1))
let normalizedY = normalize(camera.eulerAngles.y, forMinimalRotationTo: yaw)
angle = normalizedY * (1 - relativeInRange) + yaw * relativeInRange
default:
angle = yaw
}
if alignment == .vertical{
self.rotation = SCNVector4(0, 1, 1, Float.pi)
} else {
self.rotation = SCNVector4(0, 1, 0, angle)
}
}
}
My goal is to have both the rotations in the control statement at the end performed on the object. If the else statement is removed, only that rotation is applied and the square remains horizontal. I have tried making both modifications to the transform directly using SCNMatrix4MakeRotation(_:_:_:_:) but this caused the square to spin endlessly and position itself incorrectly. I also tried adjusting the node's Pivot but this resulted in no visible changes to the object.

Swift invert UIRotationGestureRecognizer for subviews

I want to invert the rotation from a UIRotationGestureRecognizer for specific subviews. My rotation occurs in 90 degree phases (so CGFloat.pi / 2 )
func localhandleRotateGesture (localrotateGesture: UIRotationGestureRecognizer) {
if localrotateGesture.state == .ended {
if localrotateGesture.rotation > 0.0005{
localrotateGesture.view?.transform = (localrotateGesture.view?.transform)!.rotated(by: CGFloat.pi / 2)
}
localrotateGesture.rotation = 0
}
Works fine. I then access subviews and try to invert the movement
var thisCell = localrotateGesture.view as! CustomMessageCell
thisCell.cellButtonsStackView.transform = (localrotateGesture.view?.transform.inverted())!.rotated(by: CGFloat.pi / 2)
The subviews rotate the first attempt, and after that it doesn't! What is going on?