From fbca4fe3d0b4e7a4616d9fc74955336e1c295efa Mon Sep 17 00:00:00 2001 From: ignacio chiazzo Date: Sun, 3 Sep 2017 00:08:41 -0400 Subject: [PATCH] Fixed some swiftlint warnings --- ARKitProject/AppDelegate.swift | 1 - ARKitProject/MainViewController.swift | 306 +++++++++--------- ARKitProject/ScieneView+Extension.swift | 2 +- ARKitProject/SettingsViewController.swift | 8 +- ARKitProject/TextManager.swift | 75 ++--- ARKitProject/UI Elements/FocusSquare.swift | 132 ++++---- ARKitProject/UI Elements/Gesture.swift | 140 ++++---- .../UI Elements/HitTestVisualization.swift | 75 ++--- ARKitProject/UI Elements/Plane.swift | 35 +- .../UI Elements/PlaneDebugVisualization.swift | 36 +-- ARKitProject/Utilities.swift | 180 ++++++----- ARKitProject/Virtual Objects/Candle.swift | 6 +- ARKitProject/Virtual Objects/Chair.swift | 4 +- ARKitProject/Virtual Objects/Cup.swift | 4 +- ARKitProject/Virtual Objects/Lamp.swift | 4 +- ARKitProject/Virtual Objects/Vase.swift | 4 +- ARKitProject/VirtualObject.swift | 37 +-- ...VirtualObjectSelectionViewController.swift | 34 +- 18 files changed, 548 insertions(+), 535 deletions(-) diff --git a/ARKitProject/AppDelegate.swift b/ARKitProject/AppDelegate.swift index 58bf021..c8b41d5 100644 --- a/ARKitProject/AppDelegate.swift +++ b/ARKitProject/AppDelegate.swift @@ -4,4 +4,3 @@ import UIKit class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? } - diff --git a/ARKitProject/MainViewController.swift b/ARKitProject/MainViewController.swift index af7f47c..2caae62 100644 --- a/ARKitProject/MainViewController.swift +++ b/ARKitProject/MainViewController.swift @@ -7,20 +7,20 @@ import Photos class MainViewController: UIViewController { var dragOnInfinitePlanesEnabled = false var currentGesture: Gesture? - + var use3DOFTrackingFallback = false var screenCenter: CGPoint? - + let session = ARSession() var sessionConfig: ARConfiguration = ARWorldTrackingConfiguration() - + var trackingFallbackTimer: Timer? - + // Use average of recent virtual object distances to avoid rapid changes in object scale. var recentVirtualObjectDistances = [CGFloat]() - + let DEFAULT_DISTANCE_CAMERA_TO_OBJECTS = Float(10) - + override func viewDidLoad() { super.viewDidLoad() @@ -35,16 +35,16 @@ class MainViewController: UIViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - + UIApplication.shared.isIdleTimerDisabled = true restartPlaneDetection() } - + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) session.pause() } - + // MARK: - ARKit / ARSCNView var use3DOFTracking = false { didSet { @@ -56,7 +56,7 @@ class MainViewController: UIViewController { } } @IBOutlet var sceneView: ARSCNView! - + // MARK: - Ambient Light Estimation func toggleAmbientLightEstimation(_ enabled: Bool) { if enabled { @@ -71,7 +71,7 @@ class MainViewController: UIViewController { } } } - + // MARK: - Virtual Object Loading var virtualObject: VirtualObject? // TODO: Remove this and create an array with virtualobject var isLoadingObject: Bool = false { @@ -84,80 +84,80 @@ class MainViewController: UIViewController { } } } - + @IBOutlet weak var addObjectButton: UIButton! - + @IBAction func chooseObject(_ button: UIButton) { // Abort if we are about to load another object to avoid concurrent modifications of the scene. if isLoadingObject { return } - + textManager.cancelScheduledMessage(forType: .contentPlacement) - + let rowHeight = 45 let popoverSize = CGSize(width: 250, height: rowHeight * VirtualObjectSelectionViewController.COUNT_OBJECTS) - + let objectViewController = VirtualObjectSelectionViewController(size: popoverSize) objectViewController.delegate = self objectViewController.modalPresentationStyle = .popover objectViewController.popoverPresentationController?.delegate = self self.present(objectViewController, animated: true, completion: nil) - + objectViewController.popoverPresentationController?.sourceView = button objectViewController.popoverPresentationController?.sourceRect = button.bounds } - + // MARK: - Planes - + var planes = [ARPlaneAnchor: Plane]() - + func addPlane(node: SCNNode, anchor: ARPlaneAnchor) { - + let pos = SCNVector3.positionFromTransform(anchor.transform) textManager.showDebugMessage("NEW SURFACE DETECTED AT \(pos.friendlyString())") - + let plane = Plane(anchor, showDebugVisuals) - + planes[anchor] = plane node.addChildNode(plane) - + textManager.cancelScheduledMessage(forType: .planeEstimation) textManager.showMessage("SURFACE DETECTED") if virtualObject == nil { textManager.scheduleMessage("TAP + TO PLACE AN OBJECT", inSeconds: 7.5, messageType: .contentPlacement) } } - + func restartPlaneDetection() { // configure session - if let worldSessionConfig = sessionConfig as? ARWorldTrackingSessionConfiguration { + if let worldSessionConfig = sessionConfig as? ARWorldTrackingConfiguration { worldSessionConfig.planeDetection = .horizontal session.run(worldSessionConfig, options: [.resetTracking, .removeExistingAnchors]) } - + // reset timer if trackingFallbackTimer != nil { trackingFallbackTimer!.invalidate() trackingFallbackTimer = nil } - + textManager.scheduleMessage("FIND A SURFACE TO PLACE AN OBJECT", inSeconds: 7.5, messageType: .planeEstimation) } // MARK: - Focus Square var focusSquare: FocusSquare? - + func setupFocusSquare() { focusSquare?.isHidden = true focusSquare?.removeFromParentNode() focusSquare = FocusSquare() sceneView.scene.rootNode.addChildNode(focusSquare!) - + textManager.scheduleMessage("TRY MOVING LEFT OR RIGHT", inSeconds: 5.0, messageType: .focusSquare) } - + func updateFocusSquare() { guard let screenCenter = screenCenter else { return } - + if virtualObject != nil && sceneView.isNode(virtualObject!, insideFrustumOf: sceneView.pointOfView!) { focusSquare?.hide() } else { @@ -169,11 +169,11 @@ class MainViewController: UIViewController { textManager.cancelScheduledMessage(forType: .focusSquare) } } - + // MARK: - Hit Test Visualization - + var hitTestVisualization: HitTestVisualization? - + var showHitTestAPIVisualization = UserDefaults.standard.bool(for: .showHitTestAPI) { didSet { UserDefaults.standard.set(showHitTestAPIVisualization, for: .showHitTestAPI) @@ -184,25 +184,25 @@ class MainViewController: UIViewController { } } } - + // MARK: - Debug Visualizations - + @IBOutlet var featurePointCountLabel: UILabel! - + func refreshFeaturePoints() { guard showDebugVisuals else { return } - + guard let cloud = session.currentFrame?.rawFeaturePoints else { return } - + DispatchQueue.main.async { self.featurePointCountLabel.text = "Features: \(cloud.__count)".uppercased() } } - + var showDebugVisuals: Bool = UserDefaults.standard.bool(for: .debugMode) { didSet { featurePointCountLabel.isHidden = !showDebugVisuals @@ -216,20 +216,20 @@ class MainViewController: UIViewController { UserDefaults.standard.set(showDebugVisuals, for: .debugMode) } } - + func setupDebug() { messagePanel.layer.cornerRadius = 3.0 messagePanel.clipsToBounds = true } - + // MARK: - UI Elements and Actions - + @IBOutlet weak var messagePanel: UIView! @IBOutlet weak var messageLabel: UILabel! @IBOutlet weak var debugMessageLabel: UILabel! - + var textManager: TextManager! - + func setupUIControls() { textManager = TextManager(viewController: self) debugMessageLabel.isHidden = true @@ -237,84 +237,86 @@ class MainViewController: UIViewController { debugMessageLabel.text = "" messageLabel.text = "" } - + @IBOutlet weak var restartExperienceButton: UIButton! var restartExperienceButtonIsEnabled = true - + @IBAction func restartExperience(_ sender: Any) { guard restartExperienceButtonIsEnabled, !isLoadingObject else { return } - + DispatchQueue.main.async { self.restartExperienceButtonIsEnabled = false - + self.textManager.cancelAllScheduledMessages() self.textManager.dismissPresentedAlert() self.textManager.showMessage("STARTING A NEW SESSION") self.use3DOFTracking = false - + self.setupFocusSquare() // self.loadVirtualObject() self.restartPlaneDetection() - + self.restartExperienceButton.setImage(#imageLiteral(resourceName: "restart"), for: []) - + // Disable Restart button for five seconds in order to give the session enough time to restart. DispatchQueue.main.asyncAfter(deadline: .now() + 5.0, execute: { self.restartExperienceButtonIsEnabled = true }) } } - + @IBOutlet weak var screenshotButton: UIButton! @IBAction func takeSnapShot() { - guard let _ = sceneView.session.currentFrame else { return } + guard sceneView.session.currentFrame != nil else { return } focusSquare?.isHidden = true - + let imagePlane = SCNPlane(width: sceneView.bounds.width / 6000, height: sceneView.bounds.height / 6000) imagePlane.firstMaterial?.diffuse.contents = sceneView.snapshot() imagePlane.firstMaterial?.lightingModel = .constant - + let planeNode = SCNNode(geometry: imagePlane) sceneView.scene.rootNode.addChildNode(planeNode) - + focusSquare?.isHidden = false } - + // MARK: - Settings - + @IBOutlet weak var settingsButton: UIButton! - + @IBAction func showSettings(_ button: UIButton) { let storyboard = UIStoryboard(name: "Main", bundle: nil) - guard let settingsViewController = storyboard.instantiateViewController(withIdentifier: "settingsViewController") as? SettingsViewController else { + guard let settingsViewController = storyboard.instantiateViewController( + withIdentifier: "settingsViewController") as? SettingsViewController else { return } - + let barButtonItem = UIBarButtonItem(barButtonSystemItem: .done, target: self, action: #selector(dismissSettings)) settingsViewController.navigationItem.rightBarButtonItem = barButtonItem settingsViewController.title = "Options" - + let navigationController = UINavigationController(rootViewController: settingsViewController) navigationController.modalPresentationStyle = .popover navigationController.popoverPresentationController?.delegate = self - navigationController.preferredContentSize = CGSize(width: sceneView.bounds.size.width - 20, height: sceneView.bounds.size.height - 50) + navigationController.preferredContentSize = CGSize(width: sceneView.bounds.size.width - 20, + height: sceneView.bounds.size.height - 50) self.present(navigationController, animated: true, completion: nil) - + navigationController.popoverPresentationController?.sourceView = settingsButton navigationController.popoverPresentationController?.sourceRect = settingsButton.bounds } - + @objc func dismissSettings() { self.dismiss(animated: true, completion: nil) updateSettings() } - + private func updateSettings() { let defaults = UserDefaults.standard - + showDebugVisuals = defaults.bool(for: .debugMode) toggleAmbientLightEstimation(defaults.bool(for: .ambientLightEstimation)) dragOnInfinitePlanesEnabled = defaults.bool(for: .dragOnInfinitePlanes) @@ -327,10 +329,10 @@ class MainViewController: UIViewController { } // MARK: - Error handling - + func displayErrorMessage(title: String, message: String, allowRestart: Bool = false) { textManager.blurBackground() - + if allowRestart { let restartAction = UIAlertAction(title: "Reset", style: .default) { _ in self.textManager.unblurBackground() @@ -351,10 +353,10 @@ extension MainViewController { self.screenCenter = self.sceneView.bounds.mid } } - + func session(_ session: ARSession, cameraDidChangeTrackingState camera: ARCamera) { textManager.showTrackingQualityInfo(for: camera.trackingState, autoHide: !self.showDebugVisuals) - + switch camera.trackingState { case .notAvailable: textManager.escalateFeedback(for: camera.trackingState, inSeconds: 5.0) @@ -377,10 +379,10 @@ extension MainViewController { } } } - + func session(_ session: ARSession, didFailWithError error: Error) { guard let arError = error as? ARError else { return } - + let nsError = error as NSError var sessionErrorMsg = "\(nsError.localizedDescription) \(nsError.localizedFailureReason ?? "")" if let recoveryOptions = nsError.localizedRecoveryOptions { @@ -388,22 +390,23 @@ extension MainViewController { sessionErrorMsg.append("\(option).") } } - + let isRecoverable = (arError.code == .worldTrackingFailed) if isRecoverable { sessionErrorMsg += "\nYou can try resetting the session or quit the application." } else { sessionErrorMsg += "\nThis is an unrecoverable error that requires to quit the application." } - + displayErrorMessage(title: "We're sorry!", message: sessionErrorMsg, allowRestart: isRecoverable) } - + func sessionWasInterrupted(_ session: ARSession) { textManager.blurBackground() - textManager.showAlert(title: "Session Interrupted", message: "The session will be reset after the interruption has ended.") + textManager.showAlert(title: "Session Interrupted", + message: "The session will be reset after the interruption has ended.") } - + func sessionInterruptionEnded(_ session: ARSession) { textManager.unblurBackground() session.run(sessionConfig, options: [.resetTracking, .removeExistingAnchors]) @@ -418,16 +421,16 @@ extension MainViewController { guard let object = virtualObject else { return } - + if currentGesture == nil { currentGesture = Gesture.startGestureFromTouches(touches, self.sceneView, object) } else { currentGesture = currentGesture!.updateGestureFromTouches(touches, .touchBegan) } - + displayVirtualObjectTransform() } - + override func touchesMoved(_ touches: Set, with event: UIEvent?) { if virtualObject == nil { return @@ -435,16 +438,16 @@ extension MainViewController { currentGesture = currentGesture?.updateGestureFromTouches(touches, .touchMoved) displayVirtualObjectTransform() } - + override func touchesEnded(_ touches: Set, with event: UIEvent?) { if virtualObject == nil { chooseObject(addObjectButton) return } - + currentGesture = currentGesture?.updateGestureFromTouches(touches, .touchEnded) } - + override func touchesCancelled(_ touches: Set, with event: UIEvent?) { if virtualObject == nil { return @@ -458,7 +461,7 @@ extension MainViewController: UIPopoverPresentationControllerDelegate { func adaptivePresentationStyle(for controller: UIPresentationController) -> UIModalPresentationStyle { return .none } - + func popoverPresentationControllerDidDismissPopover(_ popoverPresentationController: UIPopoverPresentationController) { updateSettings() } @@ -469,7 +472,7 @@ extension MainViewController :VirtualObjectSelectionViewControllerDelegate { func virtualObjectSelectionViewController(_: VirtualObjectSelectionViewController, object: VirtualObject) { loadVirtualObject(object: object) } - + func loadVirtualObject(object: VirtualObject) { // Show progress indicator let spinner = UIActivityIndicatorView() @@ -478,23 +481,23 @@ extension MainViewController :VirtualObjectSelectionViewControllerDelegate { addObjectButton.setImage(#imageLiteral(resourceName: "buttonring"), for: []) sceneView.addSubview(spinner) spinner.startAnimating() - + DispatchQueue.global().async { self.isLoadingObject = true object.viewController = self self.virtualObject = object - + object.loadModel() - + DispatchQueue.main.async { if let lastFocusSquarePos = self.focusSquare?.lastPosition { self.setNewVirtualObjectPosition(lastFocusSquarePos) } else { self.setNewVirtualObjectPosition(SCNVector3Zero) } - + spinner.removeFromSuperview() - + // Update the icon of the add object button let buttonImage = UIImage.composeButtonImage(from: object.thumbImage) let pressedButtonImage = UIImage.composeButtonImage(from: object.thumbImage, alpha: 0.3) @@ -510,11 +513,11 @@ extension MainViewController :VirtualObjectSelectionViewControllerDelegate { extension MainViewController :ARSCNViewDelegate { func renderer(_ renderer: SCNSceneRenderer, updateAtTime time: TimeInterval) { refreshFeaturePoints() - + DispatchQueue.main.async { self.updateFocusSquare() self.hitTestVisualization?.render() - + // If light estimation is enabled, update the intensity of the model's lights and the environment map if let lightEstimate = self.session.currentFrame?.lightEstimate { self.sceneView.enableEnvironmentMapWithIntensity(lightEstimate.ambientIntensity / 40) @@ -523,7 +526,7 @@ extension MainViewController :ARSCNViewDelegate { } } } - + func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) { DispatchQueue.main.async { if let planeAnchor = anchor as? ARPlaneAnchor { @@ -532,18 +535,18 @@ extension MainViewController :ARSCNViewDelegate { } } } - + func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) { DispatchQueue.main.async { if let planeAnchor = anchor as? ARPlaneAnchor { if let plane = self.planes[planeAnchor] { - plane.update(planeAnchor as! ARPlaneAnchor) + plane.update(planeAnchor) } self.checkIfObjectShouldMoveOntoPlane(anchor: planeAnchor) } } } - + func renderer(_ renderer: SCNSceneRenderer, didRemove node: SCNNode, for anchor: ARAnchor) { DispatchQueue.main.async { if let planeAnchor = anchor as? ARPlaneAnchor, let plane = self.planes.removeValue(forKey: planeAnchor) { @@ -559,25 +562,25 @@ extension MainViewController { guard let object = virtualObject, let cameraTransform = session.currentFrame?.camera.transform else { return } - + // Output the current translation, rotation & scale of the virtual object as text. let cameraPos = SCNVector3.positionFromTransform(cameraTransform) let vectorToCamera = cameraPos - object.position - + let distanceToUser = vectorToCamera.length() - + var angleDegrees = Int(((object.eulerAngles.y) * 180) / Float.pi) % 360 if angleDegrees < 0 { angleDegrees += 360 } - + let distance = String(format: "%.2f", distanceToUser) let scale = String(format: "%.2f", object.scale.x) textManager.showDebugMessage("Distance: \(distance) m\nRotation: \(angleDegrees)°\nScale: \(scale)x") } - + func moveVirtualObjectToPosition(_ pos: SCNVector3?, _ instantly: Bool, _ filterPosition: Bool) { - + guard let newPosition = pos else { textManager.showMessage("CANNOT PLACE OBJECT\nTry moving left or right.") // Reset the content selection in the menu only if the content has not yet been initially placed. @@ -586,175 +589,178 @@ extension MainViewController { } return } - + if instantly { setNewVirtualObjectPosition(newPosition) } else { updateVirtualObjectPosition(newPosition, filterPosition) } } - + func worldPositionFromScreenPosition(_ position: CGPoint, objectPos: SCNVector3?, - infinitePlane: Bool = false) -> (position: SCNVector3?, planeAnchor: ARPlaneAnchor?, hitAPlane: Bool) { - + infinitePlane: Bool = false) -> (position: SCNVector3?, + planeAnchor: ARPlaneAnchor?, + hitAPlane: Bool) { + // ------------------------------------------------------------------------------- // 1. Always do a hit test against exisiting plane anchors first. // (If any such anchors exist & only within their extents.) - + let planeHitTestResults = sceneView.hitTest(position, types: .existingPlaneUsingExtent) if let result = planeHitTestResults.first { - + let planeHitTestPosition = SCNVector3.positionFromTransform(result.worldTransform) let planeAnchor = result.anchor - + // Return immediately - this is the best possible outcome. return (planeHitTestPosition, planeAnchor as? ARPlaneAnchor, true) } - + // ------------------------------------------------------------------------------- // 2. Collect more information about the environment by hit testing against // the feature point cloud, but do not return the result yet. - + var featureHitTestPosition: SCNVector3? var highQualityFeatureHitTestResult = false - - let highQualityfeatureHitTestResults = sceneView.hitTestWithFeatures(position, coneOpeningAngleInDegrees: 18, minDistance: 0.2, maxDistance: 2.0) - + + let highQualityfeatureHitTestResults = + sceneView.hitTestWithFeatures(position, coneOpeningAngleInDegrees: 18, minDistance: 0.2, maxDistance: 2.0) + if !highQualityfeatureHitTestResults.isEmpty { let result = highQualityfeatureHitTestResults[0] featureHitTestPosition = result.position highQualityFeatureHitTestResult = true } - + // ------------------------------------------------------------------------------- // 3. If desired or necessary (no good feature hit test result): Hit test // against an infinite, horizontal plane (ignoring the real world). - + if (infinitePlane && dragOnInfinitePlanesEnabled) || !highQualityFeatureHitTestResult { - + let pointOnPlane = objectPos ?? SCNVector3Zero - + let pointOnInfinitePlane = sceneView.hitTestWithInfiniteHorizontalPlane(position, pointOnPlane) if pointOnInfinitePlane != nil { return (pointOnInfinitePlane, nil, true) } } - + // ------------------------------------------------------------------------------- // 4. If available, return the result of the hit test against high quality // features if the hit tests against infinite planes were skipped or no // infinite plane was hit. - + if highQualityFeatureHitTestResult { return (featureHitTestPosition, nil, false) } - + // ------------------------------------------------------------------------------- // 5. As a last resort, perform a second, unfiltered hit test against features. // If there are no features in the scene, the result returned here will be nil. - + let unfilteredFeatureHitTestResults = sceneView.hitTestWithFeatures(position) if !unfilteredFeatureHitTestResults.isEmpty { let result = unfilteredFeatureHitTestResults[0] return (result.position, nil, false) } - + return (nil, nil, false) } - + func setNewVirtualObjectPosition(_ pos: SCNVector3) { - + guard let object = virtualObject, let cameraTransform = session.currentFrame?.camera.transform else { return } - + recentVirtualObjectDistances.removeAll() - + let cameraWorldPos = SCNVector3.positionFromTransform(cameraTransform) var cameraToPosition = pos - cameraWorldPos cameraToPosition.setMaximumLength(DEFAULT_DISTANCE_CAMERA_TO_OBJECTS) - + object.position = cameraWorldPos + cameraToPosition - + if object.parent == nil { sceneView.scene.rootNode.addChildNode(object) } } - + func resetVirtualObject() { virtualObject?.unloadModel() virtualObject?.removeFromParentNode() virtualObject = nil - + addObjectButton.setImage(#imageLiteral(resourceName: "add"), for: []) addObjectButton.setImage(#imageLiteral(resourceName: "addPressed"), for: [.highlighted]) } - + func updateVirtualObjectPosition(_ pos: SCNVector3, _ filterPosition: Bool) { guard let object = virtualObject else { return } - + guard let cameraTransform = session.currentFrame?.camera.transform else { return } - + let cameraWorldPos = SCNVector3.positionFromTransform(cameraTransform) var cameraToPosition = pos - cameraWorldPos cameraToPosition.setMaximumLength(DEFAULT_DISTANCE_CAMERA_TO_OBJECTS) - + // Compute the average distance of the object from the camera over the last ten // updates. If filterPosition is true, compute a new position for the object // with this average. Notice that the distance is applied to the vector from // the camera to the content, so it only affects the percieved distance of the // object - the averaging does _not_ make the content "lag". let hitTestResultDistance = CGFloat(cameraToPosition.length()) - + recentVirtualObjectDistances.append(hitTestResultDistance) recentVirtualObjectDistances.keepLast(10) - + if filterPosition { let averageDistance = recentVirtualObjectDistances.average! - + cameraToPosition.setLength(Float(averageDistance)) let averagedDistancePos = cameraWorldPos + cameraToPosition - + object.position = averagedDistancePos } else { object.position = cameraWorldPos + cameraToPosition } } - + func checkIfObjectShouldMoveOntoPlane(anchor: ARPlaneAnchor) { guard let object = virtualObject, let planeAnchorNode = sceneView.node(for: anchor) else { return } - + // Get the object's position in the plane's coordinate system. let objectPos = planeAnchorNode.convertPosition(object.position, from: object.parent) - + if objectPos.y == 0 { return; // The object is already on the plane } - + // Add 10% tolerance to the corners of the plane. let tolerance: Float = 0.1 - + let minX: Float = anchor.center.x - anchor.extent.x / 2 - anchor.extent.x * tolerance let maxX: Float = anchor.center.x + anchor.extent.x / 2 + anchor.extent.x * tolerance let minZ: Float = anchor.center.z - anchor.extent.z / 2 - anchor.extent.z * tolerance let maxZ: Float = anchor.center.z + anchor.extent.z / 2 + anchor.extent.z * tolerance - + if objectPos.x < minX || objectPos.x > maxX || objectPos.z < minZ || objectPos.z > maxZ { return } - + // Drop the object onto the plane if it is near it. let verticalAllowance: Float = 0.03 if objectPos.y > -verticalAllowance && objectPos.y < verticalAllowance { textManager.showDebugMessage("OBJECT MOVED\nSurface detected nearby") - + SCNTransaction.begin() SCNTransaction.animationDuration = 0.5 SCNTransaction.animationTimingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut) diff --git a/ARKitProject/ScieneView+Extension.swift b/ARKitProject/ScieneView+Extension.swift index 40a02e6..dc32809 100644 --- a/ARKitProject/ScieneView+Extension.swift +++ b/ARKitProject/ScieneView+Extension.swift @@ -18,7 +18,7 @@ extension ARSCNView { camera.minimumExposure = -1 } } - + func enableEnvironmentMapWithIntensity(_ intensity: CGFloat) { if scene.lightingEnvironment.contents == nil { if let environmentMap = UIImage(named: "Models.scnassets/sharedImages/environment_blur.exr") { diff --git a/ARKitProject/SettingsViewController.swift b/ARKitProject/SettingsViewController.swift index 96802ee..4c89a2e 100644 --- a/ARKitProject/SettingsViewController.swift +++ b/ARKitProject/SettingsViewController.swift @@ -14,7 +14,7 @@ enum Setting: String { static func registerDefaults() { UserDefaults.standard.register(defaults: [ Setting.ambientLightEstimation.rawValue: true, - Setting.dragOnInfinitePlanes.rawValue: true, + Setting.dragOnInfinitePlanes.rawValue: true ]) } } @@ -34,7 +34,7 @@ extension UserDefaults { } class SettingsViewController: UITableViewController { - + @IBOutlet weak var debugModeSwitch: UISwitch! @IBOutlet weak var scaleWithPinchGestureSwitch: UISwitch! @IBOutlet weak var ambientLightEstimateSwitch: UISwitch! @@ -43,7 +43,7 @@ class SettingsViewController: UITableViewController { @IBOutlet weak var use3DOFTrackingSwitch: UISwitch! @IBOutlet weak var useAuto3DOFFallbackSwitch: UISwitch! @IBOutlet weak var useOcclusionPlanesSwitch: UISwitch! - + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) populateSettings() @@ -71,7 +71,7 @@ class SettingsViewController: UITableViewController { default: break } } - + private func populateSettings() { let defaults = UserDefaults.standard diff --git a/ARKitProject/TextManager.swift b/ARKitProject/TextManager.swift index 2612a7e..775d652 100644 --- a/ARKitProject/TextManager.swift +++ b/ARKitProject/TextManager.swift @@ -9,18 +9,18 @@ enum MessageType { } class TextManager { - + init(viewController: MainViewController) { self.viewController = viewController } - + func showMessage(_ text: String, autoHide: Bool = true) { messageHideTimer?.invalidate() - + viewController.messageLabel.text = text - + showHideMessage(hide: false, animated: true) - + if autoHide { let charCount = text.characters.count let displayDuration: TimeInterval = min(10, Double(charCount) / 15.0 + 1.0) @@ -31,18 +31,18 @@ class TextManager { }) } } - + func showDebugMessage(_ message: String) { guard viewController.showDebugVisuals else { return } - + debugMessageHideTimer?.invalidate() - + viewController.debugMessageLabel.text = message - + showHideDebugMessage(hide: false, animated: true) - + let charCount = message.characters.count let displayDuration: TimeInterval = min(10, Double(charCount) / 15.0 + 1.0) debugMessageHideTimer = Timer.scheduledTimer(withTimeInterval: displayDuration, @@ -51,15 +51,15 @@ class TextManager { self?.showHideDebugMessage(hide: true, animated: true) }) } - + var schedulingMessagesBlocked = false - + func scheduleMessage(_ text: String, inSeconds seconds: TimeInterval, messageType: MessageType) { // Do not schedule a new message if a feedback escalation alert is still on screen. guard !schedulingMessagesBlocked else { return } - + var timer: Timer? switch messageType { case .contentPlacement: timer = contentPlacementMessageTimer @@ -67,7 +67,7 @@ class TextManager { case .planeEstimation: timer = planeEstimationMessageTimer case .trackingStateEscalation: timer = trackingStateFeedbackEscalationTimer } - + if timer != nil { timer!.invalidate() timer = nil @@ -86,18 +86,19 @@ class TextManager { case .trackingStateEscalation: trackingStateFeedbackEscalationTimer = timer } } - + func showTrackingQualityInfo(for trackingState: ARCamera.TrackingState, autoHide: Bool) { showMessage(trackingState.presentationString, autoHide: autoHide) } - + func escalateFeedback(for trackingState: ARCamera.TrackingState, inSeconds seconds: TimeInterval) { if self.trackingStateFeedbackEscalationTimer != nil { self.trackingStateFeedbackEscalationTimer!.invalidate() self.trackingStateFeedbackEscalationTimer = nil } - - self.trackingStateFeedbackEscalationTimer = Timer.scheduledTimer(withTimeInterval: seconds, repeats: false, block: { _ in + + self.trackingStateFeedbackEscalationTimer = Timer.scheduledTimer(withTimeInterval: seconds, + repeats: false, block: { _ in self.trackingStateFeedbackEscalationTimer?.invalidate() self.trackingStateFeedbackEscalationTimer = nil self.schedulingMessagesBlocked = true @@ -117,7 +118,7 @@ class TextManager { } case .normal: break } - + let restartAction = UIAlertAction(title: "Reset", style: .destructive, handler: { _ in self.viewController.restartExperience(self) self.schedulingMessagesBlocked = false @@ -128,7 +129,7 @@ class TextManager { self.showAlert(title: title, message: message, actions: [restartAction, okAction]) }) } - + func cancelScheduledMessage(forType messageType: MessageType) { var timer: Timer? switch messageType { @@ -137,22 +138,22 @@ class TextManager { case .planeEstimation: timer = planeEstimationMessageTimer case .trackingStateEscalation: timer = trackingStateFeedbackEscalationTimer } - + if timer != nil { timer!.invalidate() timer = nil } } - + func cancelAllScheduledMessages() { cancelScheduledMessage(forType: .contentPlacement) cancelScheduledMessage(forType: .planeEstimation) cancelScheduledMessage(forType: .trackingStateEscalation) cancelScheduledMessage(forType: .focusSquare) } - + var alertController: UIAlertController? - + func showAlert(title: String, message: String, actions: [UIAlertAction]? = nil) { alertController = UIAlertController(title: title, message: message, preferredStyle: .alert) if let actions = actions { @@ -164,13 +165,13 @@ class TextManager { } self.viewController.present(alertController!, animated: true, completion: nil) } - + func dismissPresentedAlert() { alertController?.dismiss(animated: true, completion: nil) } - + let blurEffectViewTag = 100 - + func blurBackground() { let blurEffect = UIBlurEffect(style: UIBlurEffectStyle.light) let blurEffectView = UIVisualEffectView(effect: blurEffect) @@ -179,7 +180,7 @@ class TextManager { blurEffectView.tag = blurEffectViewTag viewController.view.addSubview(blurEffectView) } - + func unblurBackground() { for view in viewController.view.subviews { if let blurView = view as? UIVisualEffectView, blurView.tag == blurEffectViewTag { @@ -187,28 +188,28 @@ class TextManager { } } } - + // MARK: - Private private var viewController: MainViewController! - + // Timers for hiding regular and debug messages private var messageHideTimer: Timer? private var debugMessageHideTimer: Timer? - + // Timers for showing scheduled messages private var focusSquareMessageTimer: Timer? private var planeEstimationMessageTimer: Timer? private var contentPlacementMessageTimer: Timer? - + // Timer for tracking state escalation private var trackingStateFeedbackEscalationTimer: Timer? - + private func showHideMessage(hide: Bool, animated: Bool) { if !animated { viewController.messageLabel.isHidden = hide return } - + UIView.animate(withDuration: 0.2, delay: 0, options: [.allowUserInteraction, .beginFromCurrentState], @@ -217,13 +218,13 @@ class TextManager { self.updateMessagePanelVisibility() }, completion: nil) } - + private func showHideDebugMessage(hide: Bool, animated: Bool) { if !animated { viewController.debugMessageLabel.isHidden = hide return } - + UIView.animate(withDuration: 0.2, delay: 0, options: [.allowUserInteraction, .beginFromCurrentState], @@ -232,7 +233,7 @@ class TextManager { self.updateMessagePanelVisibility() }, completion: nil) } - + private func updateMessagePanelVisibility() { // Show and hide the panel depending whether there is something to show. viewController.messagePanel.isHidden = viewController.messageLabel.isHidden && diff --git a/ARKitProject/UI Elements/FocusSquare.swift b/ARKitProject/UI Elements/FocusSquare.swift index 85263a1..5244ad3 100644 --- a/ARKitProject/UI Elements/FocusSquare.swift +++ b/ARKitProject/UI Elements/FocusSquare.swift @@ -2,32 +2,32 @@ import Foundation import ARKit class FocusSquare: SCNNode { - + // Original size of the focus square in m. private let focusSquareSize: Float = 0.17 - + // Thickness of the focus square lines in m. private let focusSquareThickness: Float = 0.018 - + // Scale factor for the focus square when it is closed, w.r.t. the original size. private let scaleForClosedSquare: Float = 0.97 - + // Side length of the focus square segments when it is open (w.r.t. to a 1x1 square). private let sideLengthForOpenSquareSegments: CGFloat = 0.2 - + // Duration of the open/close animation private let animationDuration = 0.7 - + // Color of the focus square private let focusSquareColor = #colorLiteral(red: 1, green: 0, blue: 0, alpha: 1) // base yellow private let focusSquareColorLight = #colorLiteral(red: 0.9411764741, green: 0.4980392158, blue: 0.3529411852, alpha: 1) // light yellow - + // For scale adapdation based on the camera distance, see the `scaleBasedOnDistance(camera:)` method. ///////////////////////////////////////////////// - + var lastPositionOnPlane: SCNVector3? var lastPosition: SCNVector3? - + override init() { super.init() self.opacity = 0.0 @@ -38,11 +38,11 @@ class FocusSquare: SCNNode { recentFocusSquarePositions = [] anchorsOfVisitedPlanes = [] } - + required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + func update(for position: SCNVector3, planeAnchor: ARPlaneAnchor?, camera: ARCamera?) { lastPosition = position if let anchor = planeAnchor { @@ -54,39 +54,39 @@ class FocusSquare: SCNNode { } updateTransform(for: position, camera: camera) } - + func hide() { if self.opacity == 1.0 { self.runAction(SCNAction.fadeOut(duration: 0.5)) } } - + func unhide() { if self.opacity == 0.0 { self.runAction(SCNAction.fadeIn(duration: 0.5)) } } - + // MARK: - Private - + private var isOpen = false // use average of recent positions to avoid jitter private var recentFocusSquarePositions = [SCNVector3]() - + private var anchorsOfVisitedPlanes: Set = [] - + private func updateTransform(for position: SCNVector3, camera: ARCamera?) { 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.position = average self.setUniformScale(scaleBasedOnDistance(camera: camera)) } - + // Correct y rotation of camera square if let camera = camera { let tilt = abs(camera.eulerAngles.x) @@ -94,7 +94,7 @@ class FocusSquare: SCNNode { let threshold2: Float = 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.. Float { // Normalize angle in steps of 90 degrees such that the rotation to the other angle is minimal var normalized = angle @@ -121,7 +121,7 @@ class FocusSquare: SCNNode { } return normalized } - + private func scaleBasedOnDistance(camera: ARCamera?) -> Float { if let camera = camera { let distanceFromCamera = (self.worldPosition - SCNVector3.positionFromTransform(camera.transform)).length() @@ -130,33 +130,33 @@ class FocusSquare: SCNNode { // The values are adjusted such that scale will be 1 in 0.7 m distance (estimated distance when looking at a table), // and 1.2 in 1.5 m distance (estimated distance when looking at the floor). let newScale = distanceFromCamera < 0.7 ? (distanceFromCamera / 0.7) : (0.25 * distanceFromCamera + 0.825) - + return newScale } return 1.0 } - + private func pulseAction() -> SCNAction { let pulseOutAction = SCNAction.fadeOpacity(to: 0.4, duration: 0.5) let pulseInAction = SCNAction.fadeOpacity(to: 1.0, duration: 0.5) pulseOutAction.timingMode = .easeInEaseOut pulseInAction.timingMode = .easeInEaseOut - + return SCNAction.repeatForever(SCNAction.sequence([pulseOutAction, pulseInAction])) } - + private func stopPulsing(for node: SCNNode?) { node?.removeAction(forKey: "pulse") node?.opacity = 1.0 } - + private var isAnimating: Bool = false - + private func open() { if isOpen || isAnimating { return } - + // Open animation SCNTransaction.begin() SCNTransaction.animationTimingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut) @@ -172,14 +172,14 @@ class FocusSquare: SCNNode { self.segments?[7].open(direction: .right, newLength: sideLengthForOpenSquareSegments) SCNTransaction.completionBlock = { self.entireSquare?.runAction(self.pulseAction(), forKey: "pulse") } SCNTransaction.commit() - + // Scale/bounce animation SCNTransaction.begin() SCNTransaction.animationTimingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut) SCNTransaction.animationDuration = animationDuration / 4 entireSquare?.setUniformScale(focusSquareSize) SCNTransaction.commit() - + isOpen = true } @@ -187,11 +187,11 @@ class FocusSquare: SCNNode { if !isOpen || isAnimating { return } - + isAnimating = true - + stopPulsing(for: entireSquare) - + // Close animation SCNTransaction.begin() SCNTransaction.animationTimingFunction = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut) @@ -213,19 +213,19 @@ class FocusSquare: SCNNode { SCNTransaction.commit() } SCNTransaction.commit() - + // Scale/bounce animation entireSquare?.addAnimation(scaleAnimation(for: "transform.scale.x"), forKey: "transform.scale.x") entireSquare?.addAnimation(scaleAnimation(for: "transform.scale.y"), forKey: "transform.scale.y") entireSquare?.addAnimation(scaleAnimation(for: "transform.scale.z"), forKey: "transform.scale.z") - + // Flash if flash { let waitAction = SCNAction.wait(duration: animationDuration * 0.75) let fadeInAction = SCNAction.fadeOpacity(to: 0.25, duration: animationDuration * 0.125) let fadeOutAction = SCNAction.fadeOpacity(to: 0.0, duration: animationDuration * 0.125) fillPlane?.runAction(SCNAction.sequence([waitAction, fadeInAction, fadeOutAction])) - + let flashSquareAction = flashAnimation(duration: animationDuration * 0.25) segments?[0].runAction(SCNAction.sequence([waitAction, flashSquareAction])) segments?[1].runAction(SCNAction.sequence([waitAction, flashSquareAction])) @@ -235,12 +235,12 @@ class FocusSquare: SCNNode { segments?[5].runAction(SCNAction.sequence([waitAction, flashSquareAction])) segments?[6].runAction(SCNAction.sequence([waitAction, flashSquareAction])) segments?[7].runAction(SCNAction.sequence([waitAction, flashSquareAction])) - + } - + isOpen = false } - + private func flashAnimation(duration: TimeInterval) -> SCNAction { let action = SCNAction.customAction(duration: duration) { (node, elapsedTime) -> Void in // animate color from HSB 48/100/100 to 48/30/100 and back @@ -252,20 +252,20 @@ class FocusSquare: SCNNode { } return action } - + private func scaleAnimation(for keyPath: String) -> CAKeyframeAnimation { let scaleAnimation = CAKeyframeAnimation(keyPath: keyPath) - + let easeOut = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseOut) let easeInOut = CAMediaTimingFunction(name: kCAMediaTimingFunctionEaseInEaseOut) let linear = CAMediaTimingFunction(name: kCAMediaTimingFunctionLinear) - + let fs = focusSquareSize let ts = focusSquareSize * scaleForClosedSquare let values = [fs, fs * 1.15, fs * 1.15, ts * 0.97, ts] let keyTimes: [NSNumber] = [0.00, 0.25, 0.50, 0.75, 1.00] let timingFunctions = [easeOut, linear, easeOut, easeInOut] - + scaleAnimation.values = values scaleAnimation.keyTimes = keyTimes scaleAnimation.timingFunctions = timingFunctions @@ -273,7 +273,7 @@ class FocusSquare: SCNNode { return scaleAnimation } - + private var segments: [FocusSquareSegment]? { guard let s1 = childNode(withName: "s1", recursively: true) as? FocusSquareSegment, let s2 = childNode(withName: "s2", recursively: true) as? FocusSquareSegment, @@ -288,15 +288,15 @@ class FocusSquare: SCNNode { } return [s1, s2, s3, s4, s5, s6, s7, s8] } - + private var fillPlane: SCNNode? { return childNode(withName: "fillPlane", recursively: true) } - + private var entireSquare: SCNNode? { return self.childNodes.first } - + private func focusSquareNode() -> SCNNode { /* The focus square consists of eight segments as follows, which can be individually animated. @@ -312,7 +312,7 @@ class FocusSquare: SCNNode { let sl: Float = 0.5 // segment length let st = focusSquareThickness let c: Float = focusSquareThickness / 2 // correction to align lines perfectly - + let s1 = FocusSquareSegment(name: "s1", width: sl, thickness: st, color: focusSquareColor) let s2 = FocusSquareSegment(name: "s2", width: sl, thickness: st, color: focusSquareColor) let s3 = FocusSquareSegment(name: "s3", width: sl, thickness: st, color: focusSquareColor, vertical: true) @@ -329,14 +329,14 @@ class FocusSquare: SCNNode { s6.position += SCNVector3Make(sl, sl / 2, 0) s7.position += SCNVector3Make(-(sl / 2 - c), sl - c, 0) s8.position += SCNVector3Make(sl / 2 - c, sl - c, 0) - + let fillPlane = SCNPlane(width: CGFloat(1.0 - st * 2 + c), height: CGFloat(1.0 - st * 2 + c)) let material = SCNMaterial.material(withDiffuse: focusSquareColorLight, respondsToLighting: false) fillPlane.materials = [material] let fillPlaneNode = SCNNode(geometry: fillPlane) fillPlaneNode.name = "fillPlane" fillPlaneNode.opacity = 0.0 - + let planeNode = SCNNode() planeNode.eulerAngles = SCNVector3Make(Float.pi / 2.0, 0, 0) // Horizontal planeNode.setUniformScale(focusSquareSize * scaleForClosedSquare) @@ -349,30 +349,30 @@ class FocusSquare: SCNNode { planeNode.addChildNode(s7) planeNode.addChildNode(s8) planeNode.addChildNode(fillPlaneNode) - + isOpen = false - + // Always render focus square on top planeNode.renderOnTop() - + return planeNode } } class FocusSquareSegment: SCNNode { - + enum Direction { case up case down case left case right } - + init(name: String, width: Float, thickness: Float, color: UIColor, vertical: Bool = false) { super.init() - + let material = SCNMaterial.material(withDiffuse: color, respondsToLighting: false) - + var plane: SCNPlane if vertical { plane = SCNPlane(width: CGFloat(thickness), height: CGFloat(width)) @@ -383,22 +383,22 @@ class FocusSquareSegment: SCNNode { self.geometry = plane self.name = name } - + required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + func open(direction: Direction, newLength: CGFloat) { guard let p = self.geometry as? SCNPlane else { return } - + if direction == .left || direction == .right { p.width = newLength } else { p.height = newLength } - + switch direction { case .left: self.position.x -= Float(0.5 / 2 - p.width / 2) @@ -410,12 +410,12 @@ class FocusSquareSegment: SCNNode { self.position.y += Float(0.5 / 2 - p.height / 2) } } - + func close(direction: Direction) { guard let p = self.geometry as? SCNPlane else { return } - + var oldLength: CGFloat if direction == .left || direction == .right { oldLength = p.width @@ -424,7 +424,7 @@ class FocusSquareSegment: SCNNode { oldLength = p.height p.height = 0.5 } - + switch direction { case .left: self.position.x -= Float(0.5 / 2 - oldLength / 2) diff --git a/ARKitProject/UI Elements/Gesture.swift b/ARKitProject/UI Elements/Gesture.swift index b8bee8d..542c16e 100644 --- a/ARKitProject/UI Elements/Gesture.swift +++ b/ARKitProject/UI Elements/Gesture.swift @@ -2,33 +2,34 @@ import Foundation import ARKit class Gesture { - + enum TouchEventType { case touchBegan case touchMoved case touchEnded case touchCancelled } - + var currentTouches = Set() let sceneView: ARSCNView let virtualObject: VirtualObject - + var refreshTimer: Timer? - + init(_ touches: Set, _ sceneView: ARSCNView, _ virtualObject: VirtualObject) { currentTouches = touches self.sceneView = sceneView self.virtualObject = virtualObject - + // Refresh the current gesture at 60 Hz - This ensures smooth updates even when no // new touch events are incoming (but the camera might have moved). self.refreshTimer = Timer.scheduledTimer(withTimeInterval: 0.016_667, repeats: true, block: { _ in self.refreshCurrentGesture() }) } - - static func startGestureFromTouches(_ touches: Set, _ sceneView: ARSCNView, _ virtualObject: VirtualObject) -> Gesture? { + + static func startGestureFromTouches(_ touches: Set, _ sceneView: ARSCNView, + _ virtualObject: VirtualObject) -> Gesture? { if touches.count == 1 { return SingleFingerGesture(touches, sceneView, virtualObject) } else if touches.count == 2 { @@ -37,7 +38,7 @@ class Gesture { return nil } } - + func refreshCurrentGesture() { if let singleFingerGesture = self as? SingleFingerGesture { singleFingerGesture.updateGesture() @@ -45,22 +46,22 @@ class Gesture { twoFingerGesture.updateGesture() } } - + func updateGestureFromTouches(_ touches: Set, _ type: TouchEventType) -> Gesture? { if touches.isEmpty { // No touches -> Do nothing. return self } - + // Update the set of current touches. if type == .touchBegan || type == .touchMoved { currentTouches = touches.union(currentTouches) } else if type == .touchEnded || type == .touchCancelled { currentTouches.subtract(touches) } - + if let singleFingerGesture = self as? SingleFingerGesture { - + if currentTouches.count == 1 { // Update this gesture. singleFingerGesture.updateGesture() @@ -73,7 +74,7 @@ class Gesture { return Gesture.startGestureFromTouches(currentTouches, sceneView, virtualObject) } } else if let twoFingerGesture = self as? TwoFingerGesture { - + if currentTouches.count == 2 { // Update this gesture. twoFingerGesture.updateGesture() @@ -94,25 +95,25 @@ class Gesture { } class SingleFingerGesture: Gesture { - + var initialTouchLocation = CGPoint() var latestTouchLocation = CGPoint() - + let translationThreshold: CGFloat = 30 var translationThresholdPassed = false var hasMovedObject = false var firstTouchWasOnObject = false var dragOffset = CGPoint() - + override init(_ touches: Set, _ sceneView: ARSCNView, _ virtualObject: VirtualObject) { super.init(touches, sceneView, virtualObject) - + let touch = currentTouches[currentTouches.index(currentTouches.startIndex, offsetBy: 0)] initialTouchLocation = touch.location(in: sceneView) latestTouchLocation = initialTouchLocation - + // Check if the initial touch was on the object or not. - + var hitTestOptions = [SCNHitTestOption: Any]() hitTestOptions[SCNHitTestOption.boundingBoxOnly] = true let results: [SCNHitTestResult] = sceneView.hitTest(initialTouchLocation, options: hitTestOptions) @@ -123,62 +124,62 @@ class SingleFingerGesture: Gesture { } } } - + func updateGesture() { - + let touch = currentTouches[currentTouches.index(currentTouches.startIndex, offsetBy: 0)] latestTouchLocation = touch.location(in: sceneView) - + if !translationThresholdPassed { let initialLocationToCurrentLocation = latestTouchLocation - initialTouchLocation let distanceFromStartLocation = initialLocationToCurrentLocation.length() if distanceFromStartLocation >= translationThreshold { translationThresholdPassed = true - + let currentObjectLocation = CGPoint(sceneView.projectPoint(virtualObject.position)) dragOffset = latestTouchLocation - currentObjectLocation } } - + // A single finger drag will occur if the drag started on the object and the threshold has been passed. if translationThresholdPassed && firstTouchWasOnObject { - + let offsetPos = latestTouchLocation - dragOffset - + virtualObject.translateBasedOnScreenPos(offsetPos, instantly:false, infinitePlane:true) hasMovedObject = true } } - + func finishGesture() { - + // Single finger touch allows teleporting the object or interacting with it. - + // Do not do anything if this gesture is being finished because // another finger has started touching the screen. if currentTouches.count > 1 { return } - + // Do not do anything either if the touch has dragged the object around. if hasMovedObject { return } - + // If this gesture hasn't moved the object then perform a hit test against // the geometry to check if the user has tapped the object itself. var objectHit = false var hitTestOptions = [SCNHitTestOption: Any]() hitTestOptions[SCNHitTestOption.boundingBoxOnly] = true let results: [SCNHitTestResult] = sceneView.hitTest(latestTouchLocation, options: hitTestOptions) - + // The user has touched the virtual object. for result in results { if VirtualObject.isNodePartOfVirtualObject(result.node) { objectHit = true } } - + // In general, if this tap has hit the object itself then the object should // not be repositioned. However, if the object covers a significant // percentage of the screen then we should interpret the tap as repositioning @@ -191,39 +192,39 @@ class SingleFingerGesture: Gesture { } } } - + func approxScreenSpaceCoveredByTheObject() -> Float { - + // Perform a bunch of hit tests in a grid across the entire screen against // the bounding box of the virtual object to get a rough estimate // of how much screen space is covered by the virtual object. - + let xAxisSamples = 6 let yAxisSamples = 6 let fieldOfViewWidth: CGFloat = 0.8 let fieldOfViewHeight: CGFloat = 0.8 - + let xAxisOffset: CGFloat = (1 - fieldOfViewWidth) / 2 let yAxisOffset: CGFloat = (1 - fieldOfViewHeight) / 2 - + let stepX = fieldOfViewWidth / CGFloat(xAxisSamples - 1) let stepY = fieldOfViewHeight / CGFloat(yAxisSamples - 1) - + var successFulHits: Float = 0 - + var screenSpaceX: CGFloat = xAxisOffset var screenSpaceY: CGFloat = yAxisOffset - + var hitTestOptions = [SCNHitTestOption: Any]() hitTestOptions[SCNHitTestOption.boundingBoxOnly] = true - + for x in 0 ..< xAxisSamples { screenSpaceX = xAxisOffset + (CGFloat(x) * stepX) for y in 0 ..< yAxisSamples { screenSpaceY = yAxisOffset + (CGFloat(y) * stepY) - + let point = CGPoint(x: screenSpaceX * sceneView.frame.width, y: screenSpaceY * sceneView.frame.height) - + let results: [SCNHitTestResult] = sceneView.hitTest(point, options: hitTestOptions) for result in results { if VirtualObject.isNodePartOfVirtualObject(result.node) { @@ -233,30 +234,30 @@ class SingleFingerGesture: Gesture { } } } - + return successFulHits / (Float)(xAxisSamples * yAxisSamples) } } class TwoFingerGesture: Gesture { - + var firstTouch = UITouch() var secondTouch = UITouch() - + let translationThreshold: CGFloat = 40 let translationThresholdHarder: CGFloat = 70 var translationThresholdPassed = false var allowTranslation = false var dragOffset = CGPoint() var initialMidPoint = CGPoint(x: 0, y: 0) - + let rotationThreshold: Float = Float.pi / 15 // (12°) let rotationThresholdHarder: Float = Float.pi / 10 // (18°) var rotationThresholdPassed = false var allowRotation = false var initialFingerAngle: Float = 0 var initialObjectAngle: Float = 0 - + let scaleThreshold: CGFloat = 50 let scaleThresholdHarder: CGFloat = 90 var scaleThresholdPassed = false @@ -267,27 +268,27 @@ class TwoFingerGesture: Gesture { override init(_ touches: Set, _ sceneView: ARSCNView, _ virtualObject: VirtualObject) { super.init(touches, sceneView, virtualObject) - + firstTouch = currentTouches[currentTouches.index(currentTouches.startIndex, offsetBy: 0)] secondTouch = currentTouches[currentTouches.index(currentTouches.startIndex, offsetBy: 1)] - + let loc1 = firstTouch.location(in: sceneView) let loc2 = secondTouch.location(in: sceneView) - + let mp = (loc1 + loc2) / 2 initialMidPoint = mp - + objectBaseScale = CGFloat(virtualObject.scale.x) - + // Check if any of the two fingers or their midpoint is touching the object. // Based on that, translation, rotation and scale will be enabled or disabled. var firstTouchWasOnObject = false - + // Compute the two other corners of the rectangle defined by the two fingers // and compute the points in between. let oc1 = CGPoint(x: loc1.x, y: loc2.y) let oc2 = CGPoint(x: loc2.x, y: loc1.y) - + // Compute points in between. let dp1 = (oc1 + loc1) / 2 let dp2 = (oc1 + loc2) / 2 @@ -297,7 +298,7 @@ class TwoFingerGesture: Gesture { let dp6 = (mp + loc2) / 2 let dp7 = (mp + oc1) / 2 let dp8 = (mp + oc2) / 2 - + var hitTestOptions = [SCNHitTestOption: Any]() hitTestOptions[SCNHitTestOption.boundingBoxOnly] = true var hitTestResults = [SCNHitTestResult]() @@ -320,29 +321,30 @@ class TwoFingerGesture: Gesture { break } } - + allowTranslation = firstTouchWasOnObject allowRotation = firstTouchWasOnObject - // Allow scale if the fingers are on the object or if the object is scaled very small, and if the scale gesture has been enabled in Settings. + // Allow scale if the fingers are on the object or if the object + // is scaled very small, and if the scale gesture has been enabled in Settings. let scaleGestureEnabled = UserDefaults.standard.bool(for: .scaleWithPinchGesture) allowScaling = scaleGestureEnabled && (firstTouchWasOnObject || objectBaseScale < 0.1) - + let loc2ToLoc1 = loc1 - loc2 initialDistanceBetweenFingers = loc2ToLoc1.length() - + let midPointToLoc1 = loc2ToLoc1 / 2 initialFingerAngle = atan2(Float(midPointToLoc1.x), Float(midPointToLoc1.y)) initialObjectAngle = virtualObject.eulerAngles.y } - + func updateGesture() { - + // Two finger touch enables combined translation, rotation and scale. - + // First: Update the touches. let newTouch1 = currentTouches[currentTouches.index(currentTouches.startIndex, offsetBy: 0)] let newTouch2 = currentTouches[currentTouches.index(currentTouches.startIndex, offsetBy: 1)] - + if newTouch1.hashValue == firstTouch.hashValue { firstTouch = newTouch1 secondTouch = newTouch2 @@ -350,15 +352,15 @@ class TwoFingerGesture: Gesture { firstTouch = newTouch2 secondTouch = newTouch1 } - + let loc1 = firstTouch.location(in: sceneView) let loc2 = secondTouch.location(in: sceneView) - + if allowTranslation { // 1. Translation using the midpoint between the two fingers. updateTranslation(midpoint: loc1.midpoint(loc2)) } - + let spanBetweenTouches = loc1 - loc2 if allowRotation { // 2. Rotation based on the relative rotation of the fingers on a unit circle. @@ -471,7 +473,7 @@ class TwoFingerGesture: Gesture { } } } - + func finishGesture() { // Nothing to do here for two finger gestures. } diff --git a/ARKitProject/UI Elements/HitTestVisualization.swift b/ARKitProject/UI Elements/HitTestVisualization.swift index c0ba108..fffa150 100644 --- a/ARKitProject/UI Elements/HitTestVisualization.swift +++ b/ARKitProject/UI Elements/HitTestVisualization.swift @@ -2,108 +2,109 @@ import Foundation import ARKit class HitTestVisualization { - + var minHitDistance: CGFloat = 0.01 var maxHitDistance: CGFloat = 4.5 var xAxisSamples = 6 var yAxisSamples = 6 var fieldOfViewWidth: CGFloat = 0.8 var fieldOfViewHeight: CGFloat = 0.8 - + let hitTestPointParentNode = SCNNode() var hitTestPoints = [SCNNode]() var hitTestFeaturePoints = [SCNNode]() - + let sceneView: ARSCNView let overlayView = LineOverlayView() - + init(sceneView: ARSCNView) { self.sceneView = sceneView overlayView.backgroundColor = UIColor.clear overlayView.frame = sceneView.frame sceneView.addSubview(overlayView) } - + deinit { hitTestPointParentNode.removeFromParentNode() overlayView.removeFromSuperview() } - + func setupHitTestResultPoints() { - + if hitTestPointParentNode.parent == nil { self.sceneView.scene.rootNode.addChildNode(hitTestPointParentNode) } - + while hitTestPoints.count < xAxisSamples * yAxisSamples { hitTestPoints.append(createCrossNode(size: 0.01, color:UIColor.blue, horizontal:false)) hitTestFeaturePoints.append(createCrossNode(size: 0.01, color:UIColor.yellow, horizontal:true)) } } - + func render() { - + // Remove any old nodes, hitTestPointParentNode.childNodes.forEach { $0.removeFromParentNode() $0.geometry = nil } - + // Ensure there are enough nodes that can be rendered. setupHitTestResultPoints() - + let xAxisOffset: CGFloat = (1 - fieldOfViewWidth) / 2 let yAxisOffset: CGFloat = (1 - fieldOfViewHeight) / 2 - + let stepX = fieldOfViewWidth / CGFloat(xAxisSamples - 1) let stepY = fieldOfViewHeight / CGFloat(yAxisSamples - 1) - + var screenSpaceX: CGFloat = xAxisOffset var screenSpaceY: CGFloat = yAxisOffset - + guard let currentFrame = sceneView.session.currentFrame else { return } - + for x in 0 ..< xAxisSamples { - + screenSpaceX = xAxisOffset + (CGFloat(x) * stepX) - + for y in 0 ..< yAxisSamples { - + screenSpaceY = yAxisOffset + (CGFloat(y) * stepY) - + let hitTestPoint = hitTestPoints[(x * yAxisSamples) + y] - + let hitTestResults = currentFrame.hitTest(CGPoint(x: screenSpaceX, y: screenSpaceY), types: .featurePoint) - + if hitTestResults.isEmpty { hitTestPoint.isHidden = true continue } - + hitTestPoint.isHidden = false - + let result = hitTestResults[0] - + // Place a blue cross, oriented parallel to the screen at the place of the hit. let hitTestPointPosition = SCNVector3.positionFromTransform(result.worldTransform) - + hitTestPoint.position = hitTestPointPosition hitTestPointParentNode.addChildNode(hitTestPoint) - - // Subtract the result's local position from the world position to get the position of the feature which the ray hit. + + // Subtract the result's local position from the world position + // to get the position of the feature which the ray hit. let localPointPosition = SCNVector3.positionFromTransform(result.localTransform) let featurePosition = hitTestPointPosition - localPointPosition - + let hitTestFeaturePoint = hitTestFeaturePoints[(x * yAxisSamples) + y] - + hitTestFeaturePoint.position = featurePosition hitTestPointParentNode.addChildNode(hitTestFeaturePoint) - + // Create a 2D line between the feature point and the hit test result to be drawn on the overlay view. overlayView.addLine(start: screenPoint(for: hitTestPointPosition), end: screenPoint(for: featurePosition)) - + } } // Draw the 2D lines @@ -111,7 +112,7 @@ class HitTestVisualization { self.overlayView.setNeedsDisplay() } } - + private func screenPoint(for point: SCNVector3) -> CGPoint { let projectedPoint = sceneView.projectPoint(point) return CGPoint(x: CGFloat(projectedPoint.x), y: CGFloat(projectedPoint.y)) @@ -119,18 +120,18 @@ class HitTestVisualization { } class LineOverlayView: UIView { - + struct Line { var start: CGPoint var end: CGPoint } - + var lines = [Line]() - + func addLine(start: CGPoint, end: CGPoint) { lines.append(Line(start: start, end: end)) } - + override func draw(_ rect: CGRect) { super.draw(rect) for line in lines { diff --git a/ARKitProject/UI Elements/Plane.swift b/ARKitProject/UI Elements/Plane.swift index 3e425e1..dbda417 100644 --- a/ARKitProject/UI Elements/Plane.swift +++ b/ARKitProject/UI Elements/Plane.swift @@ -2,32 +2,32 @@ import Foundation import ARKit class Plane: SCNNode { - + var anchor: ARPlaneAnchor var occlusionNode: SCNNode? let occlusionPlaneVerticalOffset: Float = -0.01 // The occlusion plane should be placed 1 cm below the actual // plane to avoid z-fighting etc. - + var debugVisualization: PlaneDebugVisualization? - + var focusSquare: FocusSquare? - + init(_ anchor: ARPlaneAnchor, _ showDebugVisualization: Bool) { self.anchor = anchor - + super.init() - + self.showDebugVisualization(showDebugVisualization) - + if UserDefaults.standard.bool(for: .useOcclusionPlanes) { createOcclusionNode() } } - + required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + func update(_ anchor: ARPlaneAnchor) { self.anchor = anchor debugVisualization?.update(anchor) @@ -35,7 +35,7 @@ class Plane: SCNNode { updateOcclusionNode() } } - + func showDebugVisualization(_ show: Bool) { if show { if debugVisualization == nil { @@ -51,7 +51,7 @@ class Plane: SCNNode { debugVisualization = nil } } - + func updateOcclusionSetting() { if UserDefaults.standard.bool(for: .useOcclusionPlanes) { if occlusionNode == nil { @@ -62,9 +62,9 @@ class Plane: SCNNode { occlusionNode = nil } } - + // MARK: Private - + private func createOcclusionNode() { // Make the occlusion geometry slightly smaller than the plane. let occlusionPlane = SCNPlane(width: CGFloat(anchor.extent.x - 0.05), height: CGFloat(anchor.extent.z - 0.05)) @@ -72,23 +72,22 @@ class Plane: SCNNode { material.colorBufferWriteMask = [] material.isDoubleSided = true occlusionPlane.materials = [material] - + occlusionNode = SCNNode() occlusionNode!.geometry = occlusionPlane occlusionNode!.transform = SCNMatrix4MakeRotation(-Float.pi / 2.0, 1, 0, 0) occlusionNode!.position = SCNVector3Make(anchor.center.x, occlusionPlaneVerticalOffset, anchor.center.z) - + self.addChildNode(occlusionNode!) } - + private func updateOcclusionNode() { guard let occlusionNode = occlusionNode, let occlusionPlane = occlusionNode.geometry as? SCNPlane else { return } occlusionPlane.width = CGFloat(anchor.extent.x - 0.05) occlusionPlane.height = CGFloat(anchor.extent.z - 0.05) - + occlusionNode.position = SCNVector3Make(anchor.center.x, occlusionPlaneVerticalOffset, anchor.center.z) } } - diff --git a/ARKitProject/UI Elements/PlaneDebugVisualization.swift b/ARKitProject/UI Elements/PlaneDebugVisualization.swift index b9426d8..92469fd 100644 --- a/ARKitProject/UI Elements/PlaneDebugVisualization.swift +++ b/ARKitProject/UI Elements/PlaneDebugVisualization.swift @@ -2,59 +2,59 @@ import Foundation import ARKit class PlaneDebugVisualization: SCNNode { - + var planeAnchor: ARPlaneAnchor - + var planeGeometry: SCNPlane var planeNode: SCNNode - + init(anchor: ARPlaneAnchor) { - + self.planeAnchor = anchor - + let grid = UIImage(named: "Models.scnassets/plane_grid.png") self.planeGeometry = createPlane(size: CGSize(width: CGFloat(anchor.extent.x), height: CGFloat(anchor.extent.z)), contents: grid) self.planeNode = SCNNode(geometry: planeGeometry) self.planeNode.transform = SCNMatrix4MakeRotation(-Float.pi / 2.0, 1, 0, 0) - + super.init() - + let originVisualizationNode = createAxesNode(quiverLength: 0.1, quiverThickness: 1.0) self.addChildNode(originVisualizationNode) self.addChildNode(planeNode) - + self.position = SCNVector3(anchor.center.x, -0.002, anchor.center.z) // 2 mm below the origin of plane. - + adjustScale() } - + func update(_ anchor: ARPlaneAnchor) { self.planeAnchor = anchor - + self.planeGeometry.width = CGFloat(anchor.extent.x) self.planeGeometry.height = CGFloat(anchor.extent.z) - + self.position = SCNVector3Make(anchor.center.x, -0.002, anchor.center.z) - + adjustScale() } - + required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + private func adjustScale() { let scaledWidth: Float = Float(planeGeometry.width / 2.4) let scaledHeight: Float = Float(planeGeometry.height / 2.4) - + let offsetWidth: Float = -0.5 * (scaledWidth - 1) let offsetHeight: Float = -0.5 * (scaledHeight - 1) - + let material = self.planeGeometry.materials.first var transform = SCNMatrix4MakeScale(scaledWidth, scaledHeight, 1) transform = SCNMatrix4Translate(transform, offsetWidth, offsetHeight, 0) material?.diffuse.contentsTransform = transform - + } } diff --git a/ARKitProject/Utilities.swift b/ARKitProject/Utilities.swift index c7df2fd..b5a5fe1 100644 --- a/ARKitProject/Utilities.swift +++ b/ARKitProject/Utilities.swift @@ -8,16 +8,16 @@ extension UIImage { guard let ciImage = CIImage(image: self) else { return nil } - return UIImage(ciImage: ciImage.applyingFilter("CIColorInvert", parameters: [String:Any]())) + return UIImage(ciImage: ciImage.applyingFilter("CIColorInvert", parameters: [String: Any]())) } - + static func composeButtonImage(from thumbImage: UIImage, alpha: CGFloat = 1.0) -> UIImage { let maskImage = #imageLiteral(resourceName: "buttonring") var thumbnailImage = thumbImage if let invertedImage = thumbImage.inverted() { thumbnailImage = invertedImage } - + // Compose a button image based on a white background and the inverted thumbnail image. UIGraphicsBeginImageContextWithOptions(maskImage.size, false, 0.0) let maskDrawRect = CGRect(origin: CGPoint.zero, @@ -38,7 +38,7 @@ extension Array where Iterator.Element == CGFloat { guard !isEmpty else { return nil } - + var ret = self.reduce(CGFloat(0)) { (cur, next) -> CGFloat in var cur = cur cur += next @@ -55,7 +55,7 @@ extension Array where Iterator.Element == SCNVector3 { guard !isEmpty else { return nil } - + var ret = self.reduce(SCNVector3Zero) { (cur, next) -> SCNVector3 in var cur = cur cur.x += next.x @@ -67,7 +67,7 @@ extension Array where Iterator.Element == SCNVector3 { ret.x /= fcount ret.y /= fcount ret.z /= fcount - + return ret } } @@ -83,11 +83,11 @@ extension RangeReplaceableCollection where IndexDistance == Int { // MARK: - SCNNode extension extension SCNNode { - + func setUniformScale(_ scale: Float) { self.scale = SCNVector3Make(scale, scale, scale) } - + func renderOnTop() { self.renderingOrder = 2 if let geom = self.geometry { @@ -104,22 +104,22 @@ extension SCNNode { // MARK: - SCNVector3 extensions extension SCNVector3 { - + init(_ vec: vector_float3) { self.x = vec.x self.y = vec.y self.z = vec.z } - + func length() -> Float { return sqrtf(x * x + y * y + z * z) } - + mutating func setLength(_ length: Float) { self.normalize() self *= length } - + mutating func setMaximumLength(_ maxLength: Float) { if self.length() <= maxLength { return @@ -128,31 +128,31 @@ extension SCNVector3 { self *= maxLength } } - + mutating func normalize() { self = self.normalized() } - + func normalized() -> SCNVector3 { if self.length() == 0 { return self } - + return self / self.length() } - + static func positionFromTransform(_ transform: matrix_float4x4) -> SCNVector3 { return SCNVector3Make(transform.columns.3.x, transform.columns.3.y, transform.columns.3.z) } - + func friendlyString() -> String { return "(\(String(format: "%.2f", x)), \(String(format: "%.2f", y)), \(String(format: "%.2f", z)))" } - + func dot(_ vec: SCNVector3) -> Float { return (self.x * vec.x) + (self.y * vec.y) + (self.z * vec.z) } - + func cross(_ vec: SCNVector3) -> SCNVector3 { return SCNVector3(self.y * vec.z - self.z * vec.y, self.z * vec.x - self.x * vec.z, self.x * vec.y - self.y * vec.x) } @@ -203,7 +203,7 @@ func *= (left: inout SCNVector3, right: Float) { // MARK: - SCNMaterial extensions extension SCNMaterial { - + static func material(withDiffuse diffuse: Any?, respondsToLighting: Bool = true) -> SCNMaterial { let material = SCNMaterial() material.diffuse.contents = diffuse @@ -222,29 +222,29 @@ extension SCNMaterial { // MARK: - CGPoint extensions extension CGPoint { - + init(_ size: CGSize) { self.x = size.width self.y = size.height } - + init(_ vector: SCNVector3) { self.x = CGFloat(vector.x) self.y = CGFloat(vector.y) } - + func distanceTo(_ point: CGPoint) -> CGFloat { return (self - point).length() } - + func length() -> CGFloat { return sqrt(self.x * self.x + self.y * self.y) } - + func midpoint(_ point: CGPoint) -> CGPoint { return (self + point) / 2 } - + func friendlyString() -> String { return "(\(String(format: "%.2f", x)), \(String(format: "%.2f", y)))" } @@ -285,12 +285,12 @@ func *= (left: inout CGPoint, right: CGFloat) { // MARK: - CGSize extensions extension CGSize { - + init(_ point: CGPoint) { self.width = point.x self.height = point.y } - + func friendlyString() -> String { return "(\(String(format: "%.2f", width)), \(String(format: "%.2f", height)))" } @@ -331,16 +331,16 @@ func *= (left: inout CGSize, right: CGFloat) { // MARK: - CGRect extensions extension CGRect { - + var mid: CGPoint { return CGPoint(x: midX, y: midY) } } func rayIntersectionWithHorizontalPlane(rayOrigin: SCNVector3, direction: SCNVector3, planeY: Float) -> SCNVector3? { - + let direction = direction.normalized() - + // Special case handling: Check if the ray is horizontal as well. if direction.y == 0 { if rayOrigin.y == planeY { @@ -352,12 +352,12 @@ func rayIntersectionWithHorizontalPlane(rayOrigin: SCNVector3, direction: SCNVec return nil } } - + // The distance from the ray's origin to the intersection point on the plane is: // (pointOnPlane - rayOrigin) dot planeNormal // -------------------------------------------- // direction dot planeNormal - + // Since we know that horizontal planes have normal (0, 1, 0), we can simplify this to: let dist = (planeY - rayOrigin.y) / direction.y @@ -365,20 +365,20 @@ func rayIntersectionWithHorizontalPlane(rayOrigin: SCNVector3, direction: SCNVec if dist < 0 { return nil } - + // Return the intersection point. return rayOrigin + (direction * dist) } extension ARSCNView { - + struct HitTestRay { let origin: SCNVector3 let direction: SCNVector3 } - + func hitTestRayFromScreenPos(_ point: CGPoint) -> HitTestRay? { - + guard let frame = self.session.currentFrame else { return nil } @@ -388,77 +388,77 @@ extension ARSCNView { // Note: z: 1.0 will unproject() the screen position to the far clipping plane. let positionVec = SCNVector3(x: Float(point.x), y: Float(point.y), z: 1.0) let screenPosOnFarClippingPlane = self.unprojectPoint(positionVec) - + var rayDirection = screenPosOnFarClippingPlane - cameraPos rayDirection.normalize() - + return HitTestRay(origin: cameraPos, direction: rayDirection) } - + func hitTestWithInfiniteHorizontalPlane(_ point: CGPoint, _ pointOnPlane: SCNVector3) -> SCNVector3? { - + guard let ray = hitTestRayFromScreenPos(point) else { return nil } - + // Do not intersect with planes above the camera or if the ray is almost parallel to the plane. if ray.direction.y > -0.03 { return nil } - + // Return the intersection of a ray from the camera through the screen position with a horizontal plane // at height (Y axis). return rayIntersectionWithHorizontalPlane(rayOrigin: ray.origin, direction: ray.direction, planeY: pointOnPlane.y) } - + struct FeatureHitTestResult { let position: SCNVector3 let distanceToRayOrigin: Float let featureHit: SCNVector3 let featureDistanceToHitResult: Float } - + func hitTestWithFeatures(_ point: CGPoint, coneOpeningAngleInDegrees: Float, minDistance: Float = 0, maxDistance: Float = Float.greatestFiniteMagnitude, maxResults: Int = 1) -> [FeatureHitTestResult] { - + var results = [FeatureHitTestResult]() - + guard let features = self.session.currentFrame?.rawFeaturePoints else { return results } - + guard let ray = hitTestRayFromScreenPos(point) else { return results } - + let maxAngleInDeg = min(coneOpeningAngleInDegrees, 360) / 2 let maxAngle = ((maxAngleInDeg / 180) * Float.pi) - + let points = features.__points - + for i in 0...features.__count { - + let feature = points.advanced(by: Int(i)) let featurePos = SCNVector3(feature.pointee) - + let originToFeature = featurePos - ray.origin - + let crossProduct = originToFeature.cross(ray.direction) let featureDistanceFromResult = crossProduct.length() - + let hitTestResult = ray.origin + (ray.direction * ray.direction.dot(originToFeature)) let hitTestResultDistance = (hitTestResult - ray.origin).length() - + if hitTestResultDistance < minDistance || hitTestResultDistance > maxDistance { // Skip this feature - it is too close or too far away. continue } - + let originToFeatureNormalized = originToFeature.normalized() let angleBetweenRayAndFeature = acos(ray.direction.dot(originToFeatureNormalized)) - + if angleBetweenRayAndFeature > maxAngle { // Skip this feature - is is outside of the hit test cone. continue @@ -470,12 +470,12 @@ extension ARSCNView { featureHit: featurePos, featureDistanceToHitResult: featureDistanceFromResult)) } - + // Sort the results by feature distance to the ray. results = results.sorted(by: { (first, second) -> Bool in return first.distanceToRayOrigin < second.distanceToRayOrigin }) - + // Cap the list to maxResults. var cappedResults = [FeatureHitTestResult]() var i = 0 @@ -483,41 +483,41 @@ extension ARSCNView { cappedResults.append(results[i]) i += 1 } - + return cappedResults } - + func hitTestWithFeatures(_ point: CGPoint) -> [FeatureHitTestResult] { - + var results = [FeatureHitTestResult]() - + guard let ray = hitTestRayFromScreenPos(point) else { return results } - + if let result = self.hitTestFromOrigin(origin: ray.origin, direction: ray.direction) { results.append(result) } - + return results } - + func hitTestFromOrigin(origin: SCNVector3, direction: SCNVector3) -> FeatureHitTestResult? { - + guard let features = self.session.currentFrame?.rawFeaturePoints else { return nil } - + let points = features.__points - + // Determine the point from the whole point cloud which is closest to the hit test ray. var closestFeaturePoint = origin var minDistance = Float.greatestFiniteMagnitude - + for i in 0...features.__count { let feature = points.advanced(by: Int(i)) let featurePos = SCNVector3(feature.pointee) - + let originVector = origin - featurePos let crossProduct = originVector.cross(direction) let featureDistanceFromResult = crossProduct.length() @@ -527,12 +527,12 @@ extension ARSCNView { minDistance = featureDistanceFromResult } } - + // Compute the point along the ray that is closest to the selected feature. let originToFeature = closestFeaturePoint - origin let hitTestResult = origin + (direction * direction.dot(originToFeature)) let hitTestResultDistance = (hitTestResult - origin).length() - + return FeatureHitTestResult(position: hitTestResult, distanceToRayOrigin: hitTestResultDistance, featureHit: closestFeaturePoint, @@ -545,22 +545,25 @@ extension ARSCNView { func createAxesNode(quiverLength: CGFloat, quiverThickness: CGFloat) -> SCNNode { let quiverThickness = (quiverLength / 50.0) * quiverThickness let chamferRadius = quiverThickness / 2.0 - - let xQuiverBox = SCNBox(width: quiverLength, height: quiverThickness, length: quiverThickness, chamferRadius: chamferRadius) + + let xQuiverBox = SCNBox(width: quiverLength, height: quiverThickness, + length: quiverThickness, chamferRadius: chamferRadius) xQuiverBox.materials = [SCNMaterial.material(withDiffuse: UIColor.red, respondsToLighting: false)] let xQuiverNode = SCNNode(geometry: xQuiverBox) xQuiverNode.position = SCNVector3Make(Float(quiverLength / 2.0), 0.0, 0.0) - - let yQuiverBox = SCNBox(width: quiverThickness, height: quiverLength, length: quiverThickness, chamferRadius: chamferRadius) + + let yQuiverBox = SCNBox(width: quiverThickness, height: quiverLength, + length: quiverThickness, chamferRadius: chamferRadius) yQuiverBox.materials = [SCNMaterial.material(withDiffuse: UIColor.green, respondsToLighting: false)] let yQuiverNode = SCNNode(geometry: yQuiverBox) yQuiverNode.position = SCNVector3Make(0.0, Float(quiverLength / 2.0), 0.0) - - let zQuiverBox = SCNBox(width: quiverThickness, height: quiverThickness, length: quiverLength, chamferRadius: chamferRadius) + + let zQuiverBox = SCNBox(width: quiverThickness, height: quiverThickness, + length: quiverLength, chamferRadius: chamferRadius) zQuiverBox.materials = [SCNMaterial.material(withDiffuse: UIColor.blue, respondsToLighting: false)] let zQuiverNode = SCNNode(geometry: zQuiverBox) zQuiverNode.position = SCNVector3Make(0.0, 0.0, Float(quiverLength / 2.0)) - + let quiverNode = SCNNode() quiverNode.addChildNode(xQuiverNode) quiverNode.addChildNode(yQuiverNode) @@ -569,11 +572,12 @@ func createAxesNode(quiverLength: CGFloat, quiverThickness: CGFloat) -> SCNNode return quiverNode } -func createCrossNode(size: CGFloat = 0.01, color: UIColor = UIColor.green, horizontal: Bool = true, opacity: CGFloat = 1.0) -> SCNNode { - +func createCrossNode(size: CGFloat = 0.01, color: UIColor = UIColor.green, + horizontal: Bool = true, opacity: CGFloat = 1.0) -> SCNNode { + // Create a size x size m plane and put a grid texture onto it. let planeDimension = size - + var fileName = "" switch color { case UIColor.blue: @@ -583,22 +587,22 @@ func createCrossNode(size: CGFloat = 0.01, color: UIColor = UIColor.green, horiz default: fileName = "crosshair_yellow" } - + let path = Bundle.main.path(forResource: fileName, ofType: "png", inDirectory: "Models.scnassets")! let image = UIImage(contentsOfFile: path) - + let planeNode = SCNNode(geometry: createSquarePlane(size: planeDimension, contents: image)) if let material = planeNode.geometry?.firstMaterial { material.ambient.contents = UIColor.black material.lightingModel = .constant } - + if horizontal { planeNode.eulerAngles = SCNVector3Make(Float.pi / 2.0, 0, Float.pi) // Horizontal. } else { planeNode.constraints = [SCNBillboardConstraint()] // Facing the screen. } - + let cross = SCNNode() cross.addChildNode(planeNode) cross.opacity = opacity diff --git a/ARKitProject/Virtual Objects/Candle.swift b/ARKitProject/Virtual Objects/Candle.swift index 36bfacf..ec73df0 100644 --- a/ARKitProject/Virtual Objects/Candle.swift +++ b/ARKitProject/Virtual Objects/Candle.swift @@ -2,15 +2,15 @@ import Foundation import SceneKit class Candle: VirtualObject, ReactsToScale { - + override init() { super.init(modelName: "candle", fileExtension: "scn", thumbImageFilename: "candle", title: "Candle") } - + required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + func reactToScale() { // Update the size of the flame let flameNode = self.childNode(withName: "flame", recursively: true) diff --git a/ARKitProject/Virtual Objects/Chair.swift b/ARKitProject/Virtual Objects/Chair.swift index a207c8e..eb743d0 100644 --- a/ARKitProject/Virtual Objects/Chair.swift +++ b/ARKitProject/Virtual Objects/Chair.swift @@ -1,11 +1,11 @@ import Foundation class Chair: VirtualObject { - + override init() { super.init(modelName: "chair", fileExtension: "scn", thumbImageFilename: "chair", title: "Chair") } - + required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } diff --git a/ARKitProject/Virtual Objects/Cup.swift b/ARKitProject/Virtual Objects/Cup.swift index 39d3586..dd729be 100644 --- a/ARKitProject/Virtual Objects/Cup.swift +++ b/ARKitProject/Virtual Objects/Cup.swift @@ -1,11 +1,11 @@ import Foundation class Cup: VirtualObject { - + override init() { super.init(modelName: "cup", fileExtension: "scn", thumbImageFilename: "cup", title: "Cup") } - + required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } diff --git a/ARKitProject/Virtual Objects/Lamp.swift b/ARKitProject/Virtual Objects/Lamp.swift index b206772..a13eaeb 100644 --- a/ARKitProject/Virtual Objects/Lamp.swift +++ b/ARKitProject/Virtual Objects/Lamp.swift @@ -2,11 +2,11 @@ import Foundation import ARKit class Lamp: VirtualObject { - + override init() { super.init(modelName: "lamp", fileExtension: "scn", thumbImageFilename: "lamp", title: "Lamp") } - + required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } diff --git a/ARKitProject/Virtual Objects/Vase.swift b/ARKitProject/Virtual Objects/Vase.swift index 88056ed..dc505e0 100644 --- a/ARKitProject/Virtual Objects/Vase.swift +++ b/ARKitProject/Virtual Objects/Vase.swift @@ -1,11 +1,11 @@ import Foundation class Vase: VirtualObject { - + override init() { super.init(modelName: "vase", fileExtension: "scn", thumbImageFilename: "vase", title: "Vase") } - + required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } diff --git a/ARKitProject/VirtualObject.swift b/ARKitProject/VirtualObject.swift index 12f71db..b672ca6 100644 --- a/ARKitProject/VirtualObject.swift +++ b/ARKitProject/VirtualObject.swift @@ -9,14 +9,14 @@ class VirtualObject: SCNNode { var thumbImage: UIImage! var title: String = "" var modelLoaded: Bool = false - + var viewController: MainViewController? - + override init() { super.init() self.name = VirtualObject.ROOT_NAME } - + init(modelName: String, fileExtension: String, thumbImageFilename: String, title: String) { super.init() self.name = VirtualObject.ROOT_NAME @@ -25,36 +25,37 @@ class VirtualObject: SCNNode { self.thumbImage = UIImage(named: thumbImageFilename) self.title = title } - + required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + func loadModel() { - guard let virtualObjectScene = SCNScene(named: "\(modelName).\(fileExtension)", inDirectory: "Models.scnassets/\(modelName)") else { + guard let virtualObjectScene = SCNScene(named: "\(modelName).\(fileExtension)", + inDirectory: "Models.scnassets/\(modelName)") else { return } - + let wrapperNode = SCNNode() - + for child in virtualObjectScene.rootNode.childNodes { child.geometry?.firstMaterial?.lightingModel = .physicallyBased child.movabilityHint = .movable wrapperNode.addChildNode(child) } self.addChildNode(wrapperNode) - + modelLoaded = true } - + func unloadModel() { for child in self.childNodes { child.removeFromParentNode() } - + modelLoaded = false } - + func translateBasedOnScreenPos(_ pos: CGPoint, instantly: Bool, infinitePlane: Bool) { guard let controller = viewController else { return @@ -65,16 +66,16 @@ class VirtualObject: SCNNode { } extension VirtualObject { - + static func isNodePartOfVirtualObject(_ node: SCNNode) -> Bool { if node.name == VirtualObject.ROOT_NAME { return true } - + if node.parent != nil { return isNodePartOfVirtualObject(node.parent!) } - + return false } } @@ -86,16 +87,16 @@ protocol ReactsToScale { } extension SCNNode { - + func reactsToScale() -> ReactsToScale? { if let canReact = self as? ReactsToScale { return canReact } - + if parent != nil { return parent!.reactsToScale() } - + return nil } } diff --git a/ARKitProject/VirtualObjectSelectionViewController.swift b/ARKitProject/VirtualObjectSelectionViewController.swift index d865c8b..9b7d963 100644 --- a/ARKitProject/VirtualObjectSelectionViewController.swift +++ b/ARKitProject/VirtualObjectSelectionViewController.swift @@ -1,23 +1,23 @@ import UIKit class VirtualObjectSelectionViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { - + private var tableView: UITableView! private var size: CGSize! weak var delegate: VirtualObjectSelectionViewControllerDelegate? - + init(size: CGSize) { super.init(nibName: nil, bundle: nil) self.size = size } - + required init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + override func viewDidLoad() { super.viewDidLoad() - + tableView = UITableView() tableView.frame = CGRect(origin: CGPoint.zero, size: self.size) tableView.dataSource = self @@ -26,12 +26,12 @@ class VirtualObjectSelectionViewController: UIViewController, UITableViewDataSou tableView.separatorEffect = UIVibrancyEffect(blurEffect: UIBlurEffect(style: .light)) tableView.separatorInset = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) tableView.bounces = false - + self.preferredContentSize = self.size - + self.view.addSubview(tableView) } - + func getObject(index: Int) -> VirtualObject { switch index { case 0: @@ -48,25 +48,25 @@ class VirtualObjectSelectionViewController: UIViewController, UITableViewDataSou return Cup() } } - + static let COUNT_OBJECTS = 5 - + // MARK: - UITableViewDelegate func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { delegate?.virtualObjectSelectionViewController(self, object: getObject(index: indexPath.row)) self.dismiss(animated: true, completion: nil) } - + // MARK: - UITableViewDataSource func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return VirtualObjectSelectionViewController.COUNT_OBJECTS } - + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = UITableViewCell() let label = UILabel(frame: CGRect(x: 53, y: 10, width: 200, height: 30)) let icon = UIImageView(frame: CGRect(x: 15, y: 10, width: 30, height: 30)) - + cell.backgroundColor = UIColor.clear cell.selectionStyle = .none let vibrancyEffect = UIVibrancyEffect(blurEffect: UIBlurEffect(style: .extraLight)) @@ -75,7 +75,7 @@ class VirtualObjectSelectionViewController: UIViewController, UITableViewDataSou cell.contentView.insertSubview(vibrancyView, at: 0) vibrancyView.contentView.addSubview(label) vibrancyView.contentView.addSubview(icon) - + // Fill up the cell with data from the object. let object = getObject(index: indexPath.row) var thumbnailImage = object.thumbImage! @@ -84,15 +84,15 @@ class VirtualObjectSelectionViewController: UIViewController, UITableViewDataSou } label.text = object.title icon.image = thumbnailImage - + return cell } - + func tableView(_ tableView: UITableView, didHighlightRowAt indexPath: IndexPath) { let cell = tableView.cellForRow(at: indexPath) cell?.backgroundColor = UIColor.lightGray.withAlphaComponent(0.5) } - + func tableView(_ tableView: UITableView, didUnhighlightRowAt indexPath: IndexPath) { let cell = tableView.cellForRow(at: indexPath) cell?.backgroundColor = UIColor.clear