├── PencilController
├── DSCF0786.jpg
├── Assets.xcassets
│ └── AppIcon.appiconset
│ │ └── Contents.json
├── Info.plist
├── Base.lproj
│ ├── Main.storyboard
│ └── LaunchScreen.storyboard
├── AppDelegate.swift
└── ViewController.swift
├── PencilController.xcodeproj
├── project.xcworkspace
│ └── contents.xcworkspacedata
├── xcuserdata
│ └── simongladman.xcuserdatad
│ │ └── xcschemes
│ │ ├── xcschememanagement.plist
│ │ └── PencilController.xcscheme
└── project.pbxproj
└── README.md
/PencilController/DSCF0786.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/FlexMonkey/PencilController/HEAD/PencilController/DSCF0786.jpg
--------------------------------------------------------------------------------
/PencilController.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/PencilController.xcodeproj/xcuserdata/simongladman.xcuserdatad/xcschemes/xcschememanagement.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | SchemeUserState
6 |
7 | PencilController.xcscheme
8 |
9 | orderHint
10 | 0
11 |
12 |
13 | SuppressBuildableAutocreation
14 |
15 | 3EC2AEA51C005567005CBE16
16 |
17 | primary
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/PencilController/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "ipad",
5 | "size" : "29x29",
6 | "scale" : "1x"
7 | },
8 | {
9 | "idiom" : "ipad",
10 | "size" : "29x29",
11 | "scale" : "2x"
12 | },
13 | {
14 | "idiom" : "ipad",
15 | "size" : "40x40",
16 | "scale" : "1x"
17 | },
18 | {
19 | "idiom" : "ipad",
20 | "size" : "40x40",
21 | "scale" : "2x"
22 | },
23 | {
24 | "idiom" : "ipad",
25 | "size" : "76x76",
26 | "scale" : "1x"
27 | },
28 | {
29 | "idiom" : "ipad",
30 | "size" : "76x76",
31 | "scale" : "2x"
32 | }
33 | ],
34 | "info" : {
35 | "version" : 1,
36 | "author" : "xcode"
37 | }
38 | }
--------------------------------------------------------------------------------
/PencilController/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | en
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | APPL
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleSignature
20 | ????
21 | CFBundleVersion
22 | 1
23 | LSRequiresIPhoneOS
24 |
25 | UILaunchStoryboardName
26 | LaunchScreen
27 | UIMainStoryboardFile
28 | Main
29 | UIRequiredDeviceCapabilities
30 |
31 | armv7
32 |
33 | UISupportedInterfaceOrientations~ipad
34 |
35 | UIInterfaceOrientationPortrait
36 | UIInterfaceOrientationPortraitUpsideDown
37 | UIInterfaceOrientationLandscapeLeft
38 | UIInterfaceOrientationLandscapeRight
39 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/PencilController/Base.lproj/Main.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/PencilController/Base.lproj/LaunchScreen.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/PencilController/AppDelegate.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AppDelegate.swift
3 | // PencilController
4 | //
5 | // Created by Simon Gladman on 21/11/2015.
6 | // Copyright © 2015 Simon Gladman. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | @UIApplicationMain
12 | class AppDelegate: UIResponder, UIApplicationDelegate {
13 |
14 | var window: UIWindow?
15 |
16 |
17 | func application(application: UIApplication, didFinishLaunchingWithOptions launchOptions: [NSObject: AnyObject]?) -> Bool {
18 | // Override point for customization after application launch.
19 | return true
20 | }
21 |
22 | func applicationWillResignActive(application: UIApplication) {
23 | // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state.
24 | // Use this method to pause ongoing tasks, disable timers, and throttle down OpenGL ES frame rates. Games should use this method to pause the game.
25 | }
26 |
27 | func applicationDidEnterBackground(application: UIApplication) {
28 | // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later.
29 | // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits.
30 | }
31 |
32 | func applicationWillEnterForeground(application: UIApplication) {
33 | // Called as part of the transition from the background to the inactive state; here you can undo many of the changes made on entering the background.
34 | }
35 |
36 | func applicationDidBecomeActive(application: UIApplication) {
37 | // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface.
38 | }
39 |
40 | func applicationWillTerminate(application: UIApplication) {
41 | // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:.
42 | }
43 |
44 |
45 | }
46 |
47 |
--------------------------------------------------------------------------------
/PencilController.xcodeproj/xcuserdata/simongladman.xcuserdatad/xcschemes/PencilController.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
30 |
31 |
32 |
33 |
39 |
40 |
41 |
42 |
43 |
44 |
54 |
56 |
62 |
63 |
64 |
65 |
66 |
67 |
73 |
75 |
81 |
82 |
83 |
84 |
86 |
87 |
90 |
91 |
92 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # PencilController
2 | ##### Using Apple Pencil as a 3D Controller for Image Editing
3 |
4 | ##### _Companion project to this blog post: http://flexmonkey.blogspot.co.uk/2015/11/pencilcontroller-using-apple-pencil-as.html_
5 |
6 | Despite Jony Ive describing the Pencil as being designed for marking and not as a stylus finger replacement in Wallpaper*, I've decided to explore a few unconventional uses for mine. Yesterday saw a slightly ramshackle looking Pencil based electronic scale and today I'm using it as a joystick of sorts for controlling parameters on image filters.
7 |
8 | My PencilController project is a Swift app for iPad Pro that applies two Core Image filters to an image: a hue adjustment and a colour controls which I use to control the saturation.
9 |
10 | The Pencil's orientation in space is described by the Horizontal Coordinate System with azimuth and altitude angles.
11 |
12 | The hue filter's value is controlled by the azimuth angle and the saturation is controlled by the altitude angle: when the pencil is vertical, the saturation is zero and when it's horizontal the saturation is eight (although when the pencil is totally horizontal, its tip isn't actually touching the screen, so the highest saturation the app can set is about six and three quarters).
13 |
14 | To jazz up the user interface, I've also added a rounded cylinder using SceneKit which mirrors the Pencil's position and orientation.
15 |
16 | ## Controlling Core Image Filter Parameters with Pencil
17 |
18 | Setting the values for the two Core Image filters is pretty simple stuff. Both filters are declared as constants at the top of my view controller along with a Core Image context (without colour management for performance) and a Core Image image:
19 |
20 | ```swift
21 | let hueAdjust = CIFilter(name: "CIHueAdjust")!
22 | let colorControls = CIFilter(name: "CIColorControls")!
23 |
24 | let ciContext = CIContext(EAGLContext: EAGLContext(API: EAGLRenderingAPI.OpenGLES2),
25 | options: [kCIContextWorkingColorSpace: NSNull()])
26 |
27 | let coreImage = CIImage(image: UIImage(named: "DSCF0786.jpg")!)!
28 | ```
29 |
30 | When the touch either starts or changes, I want to ensure it originates from a Pencil by checking its type and then invoke `applyFilter()` via `pencilTouchHandler()` method:
31 |
32 | ```swift
33 | override func touchesMoved(touches: Set, withEvent event: UIEvent?)
34 | {
35 | guard let touch = touches.first where
36 | touch.type == UITouchType.Stylus else
37 | {
38 | return
39 | }
40 |
41 | pencilTouchHandler(touch)
42 | }
43 | ```
44 |
45 | `pencilTouchHandler()` extracts the azimuth and altitude angles from the `UITouch`, does some simple arithmetic and passes those values to `applyFilter()`:
46 |
47 | ```swift
48 | applyFilter(hueAngle: pi + touch.azimuthAngleInView(view),
49 | saturation: 8 * ((halfPi - touch.altitudeAngle) / halfPi))
50 | ```
51 |
52 | It's `applyFilter()` that uses those two values to set the parameters on the filters and display the output in a UIImageView:
53 |
54 | ```swift
55 | func applyFilter(hueAngle hueAngle: CGFloat, saturation: CGFloat)
56 | {
57 | hueAdjust.setValue(coreImage,
58 | forKey: kCIInputImageKey)
59 | hueAdjust.setValue(hueAngle,
60 | forKey: kCIInputAngleKey)
61 |
62 | colorControls.setValue(hueAdjust.valueForKey(kCIOutputImageKey) as! CIImage,
63 | forKey: kCIInputImageKey)
64 | colorControls.setValue(saturation,
65 | forKey: kCIInputSaturationKey)
66 |
67 | let cgImage = ciContext.createCGImage(colorControls.valueForKey(kCIOutputImageKey) as! CIImage,
68 | fromRect: coreImage.extent)
69 |
70 | imageView.image = UIImage(CGImage: cgImage)
71 |
72 | label.text = String(format: "Hue: %.2f°", hueAngle * 180 / pi) + " " + String(format: "Saturation: %.2f", saturation)
73 | }
74 | ```
75 |
76 | On my iPad Pro this filtering is fast enough on a near full screen image that I don't have to worry about doing this work in a background thread.
77 |
78 | ## Controlling SceneKit Geometry with Pencil
79 |
80 | The next piece of work is to orient and position the "virtual pencil" so it mirrors the real one. I've overlaid a `SCNView` above the `UIImageView` and added a capsule geometry (which is a cylinder with rounded ends, not unlike a Pencil). Importantly, I've also added a flat plane which is used to capture the Pencil's location in the SceneKit 3D space:
81 |
82 | ```swift
83 | let sceneKitView = SCNView()
84 | let scene = SCNScene()
85 | let cylinderNode = SCNNode(geometry: SCNCapsule(capRadius: 0.05, height: 1))
86 | let plane = SCNNode(geometry: SCNPlane(width: 20, height: 20))
87 |
88 | // in init()
89 | sceneKitView.scene = scene
90 | scene.rootNode.addChildNode(cameraNode)
91 | scene.rootNode.addChildNode(cylinderNode)
92 | scene.rootNode.addChildNode(plane)
93 | ```
94 |
95 | Inside the `pencilTouchHandler()`, I use the SceneKit view's `hitTest()` method to find the x and y positions of the Pencil on the screen in SceneKit's 3D space on the plane:
96 |
97 | ```swift
98 | func pencilTouchHandler(touch: UITouch)
99 | {
100 | guard let hitTestResult:SCNHitTestResult = sceneKitView.hitTest(touch.locationInView(view), options: nil)
101 | .filter( { $0.node == plane })
102 | .first else
103 | {
104 | return
105 | }
106 | [...]
107 | ```
108 |
109 | ...and with the results of that hit test, I can position the cylinder underneath the Pencil's touch location:
110 |
111 | ```swift
112 | [...]
113 | cylinderNode.position = SCNVector3(hitTestResult.localCoordinates.x,
114 | hitTestResult.localCoordinates.y,
115 | 0)
116 | [...]
117 | ```
118 |
119 | Finally, with the altitude and azimuth angles of the touch, I can set the Euler angles of the cylinder to match the Pencil:
120 |
121 | ```swift
122 | [...]
123 | cylinderNode.eulerAngles = SCNVector3(touch.altitudeAngle,
124 | 0.0,
125 | 0 - touch.azimuthAngleInView(view) - halfPi)
126 | [...]
127 | ```
128 |
129 | I've made the SceneKit camera orthographic, a perspective camera adds unwanted rotation to the "virtual pencil" as it moves across the screen.
130 |
131 | ## Conclusion
132 |
133 | Despite what Jony Ive may say, the Pencil offers some user interaction patterns impossible with a simple touch screen and I hope other developers start exploring new ideas. In addition to the two angles, the Pencil also has x and y coordinates and its force, so that's five different values that could potentially be used for controlling anything, from image filters to an audio synthesiser!
134 |
135 | As always, the source code for this project is available at my GitHub repository here. Enjoy!
136 |
--------------------------------------------------------------------------------
/PencilController.xcodeproj/project.pbxproj:
--------------------------------------------------------------------------------
1 | // !$*UTF8*$!
2 | {
3 | archiveVersion = 1;
4 | classes = {
5 | };
6 | objectVersion = 46;
7 | objects = {
8 |
9 | /* Begin PBXBuildFile section */
10 | 3EC2AEAA1C005567005CBE16 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EC2AEA91C005567005CBE16 /* AppDelegate.swift */; };
11 | 3EC2AEAC1C005567005CBE16 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3EC2AEAB1C005567005CBE16 /* ViewController.swift */; };
12 | 3EC2AEAF1C005567005CBE16 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 3EC2AEAD1C005567005CBE16 /* Main.storyboard */; };
13 | 3EC2AEB11C005567005CBE16 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3EC2AEB01C005567005CBE16 /* Assets.xcassets */; };
14 | 3EC2AEB41C005567005CBE16 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 3EC2AEB21C005567005CBE16 /* LaunchScreen.storyboard */; };
15 | 3EC2AEBC1C008794005CBE16 /* DSCF0786.jpg in Resources */ = {isa = PBXBuildFile; fileRef = 3EC2AEBB1C008794005CBE16 /* DSCF0786.jpg */; };
16 | /* End PBXBuildFile section */
17 |
18 | /* Begin PBXFileReference section */
19 | 3EC2AEA61C005567005CBE16 /* PencilController.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = PencilController.app; sourceTree = BUILT_PRODUCTS_DIR; };
20 | 3EC2AEA91C005567005CBE16 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
21 | 3EC2AEAB1C005567005CBE16 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; };
22 | 3EC2AEAE1C005567005CBE16 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
23 | 3EC2AEB01C005567005CBE16 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
24 | 3EC2AEB31C005567005CBE16 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
25 | 3EC2AEB51C005567005CBE16 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
26 | 3EC2AEBB1C008794005CBE16 /* DSCF0786.jpg */ = {isa = PBXFileReference; lastKnownFileType = image.jpeg; path = DSCF0786.jpg; sourceTree = ""; };
27 | /* End PBXFileReference section */
28 |
29 | /* Begin PBXFrameworksBuildPhase section */
30 | 3EC2AEA31C005567005CBE16 /* Frameworks */ = {
31 | isa = PBXFrameworksBuildPhase;
32 | buildActionMask = 2147483647;
33 | files = (
34 | );
35 | runOnlyForDeploymentPostprocessing = 0;
36 | };
37 | /* End PBXFrameworksBuildPhase section */
38 |
39 | /* Begin PBXGroup section */
40 | 3EC2AE9D1C005567005CBE16 = {
41 | isa = PBXGroup;
42 | children = (
43 | 3EC2AEA81C005567005CBE16 /* PencilController */,
44 | 3EC2AEA71C005567005CBE16 /* Products */,
45 | );
46 | sourceTree = "";
47 | };
48 | 3EC2AEA71C005567005CBE16 /* Products */ = {
49 | isa = PBXGroup;
50 | children = (
51 | 3EC2AEA61C005567005CBE16 /* PencilController.app */,
52 | );
53 | name = Products;
54 | sourceTree = "";
55 | };
56 | 3EC2AEA81C005567005CBE16 /* PencilController */ = {
57 | isa = PBXGroup;
58 | children = (
59 | 3EC2AEBB1C008794005CBE16 /* DSCF0786.jpg */,
60 | 3EC2AEA91C005567005CBE16 /* AppDelegate.swift */,
61 | 3EC2AEAB1C005567005CBE16 /* ViewController.swift */,
62 | 3EC2AEAD1C005567005CBE16 /* Main.storyboard */,
63 | 3EC2AEB01C005567005CBE16 /* Assets.xcassets */,
64 | 3EC2AEB21C005567005CBE16 /* LaunchScreen.storyboard */,
65 | 3EC2AEB51C005567005CBE16 /* Info.plist */,
66 | );
67 | path = PencilController;
68 | sourceTree = "";
69 | };
70 | /* End PBXGroup section */
71 |
72 | /* Begin PBXNativeTarget section */
73 | 3EC2AEA51C005567005CBE16 /* PencilController */ = {
74 | isa = PBXNativeTarget;
75 | buildConfigurationList = 3EC2AEB81C005567005CBE16 /* Build configuration list for PBXNativeTarget "PencilController" */;
76 | buildPhases = (
77 | 3EC2AEA21C005567005CBE16 /* Sources */,
78 | 3EC2AEA31C005567005CBE16 /* Frameworks */,
79 | 3EC2AEA41C005567005CBE16 /* Resources */,
80 | );
81 | buildRules = (
82 | );
83 | dependencies = (
84 | );
85 | name = PencilController;
86 | productName = PencilController;
87 | productReference = 3EC2AEA61C005567005CBE16 /* PencilController.app */;
88 | productType = "com.apple.product-type.application";
89 | };
90 | /* End PBXNativeTarget section */
91 |
92 | /* Begin PBXProject section */
93 | 3EC2AE9E1C005567005CBE16 /* Project object */ = {
94 | isa = PBXProject;
95 | attributes = {
96 | LastSwiftUpdateCheck = 0710;
97 | LastUpgradeCheck = 0710;
98 | ORGANIZATIONNAME = "Simon Gladman";
99 | TargetAttributes = {
100 | 3EC2AEA51C005567005CBE16 = {
101 | CreatedOnToolsVersion = 7.1.1;
102 | };
103 | };
104 | };
105 | buildConfigurationList = 3EC2AEA11C005567005CBE16 /* Build configuration list for PBXProject "PencilController" */;
106 | compatibilityVersion = "Xcode 3.2";
107 | developmentRegion = English;
108 | hasScannedForEncodings = 0;
109 | knownRegions = (
110 | en,
111 | Base,
112 | );
113 | mainGroup = 3EC2AE9D1C005567005CBE16;
114 | productRefGroup = 3EC2AEA71C005567005CBE16 /* Products */;
115 | projectDirPath = "";
116 | projectRoot = "";
117 | targets = (
118 | 3EC2AEA51C005567005CBE16 /* PencilController */,
119 | );
120 | };
121 | /* End PBXProject section */
122 |
123 | /* Begin PBXResourcesBuildPhase section */
124 | 3EC2AEA41C005567005CBE16 /* Resources */ = {
125 | isa = PBXResourcesBuildPhase;
126 | buildActionMask = 2147483647;
127 | files = (
128 | 3EC2AEBC1C008794005CBE16 /* DSCF0786.jpg in Resources */,
129 | 3EC2AEB41C005567005CBE16 /* LaunchScreen.storyboard in Resources */,
130 | 3EC2AEB11C005567005CBE16 /* Assets.xcassets in Resources */,
131 | 3EC2AEAF1C005567005CBE16 /* Main.storyboard in Resources */,
132 | );
133 | runOnlyForDeploymentPostprocessing = 0;
134 | };
135 | /* End PBXResourcesBuildPhase section */
136 |
137 | /* Begin PBXSourcesBuildPhase section */
138 | 3EC2AEA21C005567005CBE16 /* Sources */ = {
139 | isa = PBXSourcesBuildPhase;
140 | buildActionMask = 2147483647;
141 | files = (
142 | 3EC2AEAC1C005567005CBE16 /* ViewController.swift in Sources */,
143 | 3EC2AEAA1C005567005CBE16 /* AppDelegate.swift in Sources */,
144 | );
145 | runOnlyForDeploymentPostprocessing = 0;
146 | };
147 | /* End PBXSourcesBuildPhase section */
148 |
149 | /* Begin PBXVariantGroup section */
150 | 3EC2AEAD1C005567005CBE16 /* Main.storyboard */ = {
151 | isa = PBXVariantGroup;
152 | children = (
153 | 3EC2AEAE1C005567005CBE16 /* Base */,
154 | );
155 | name = Main.storyboard;
156 | sourceTree = "";
157 | };
158 | 3EC2AEB21C005567005CBE16 /* LaunchScreen.storyboard */ = {
159 | isa = PBXVariantGroup;
160 | children = (
161 | 3EC2AEB31C005567005CBE16 /* Base */,
162 | );
163 | name = LaunchScreen.storyboard;
164 | sourceTree = "";
165 | };
166 | /* End PBXVariantGroup section */
167 |
168 | /* Begin XCBuildConfiguration section */
169 | 3EC2AEB61C005567005CBE16 /* Debug */ = {
170 | isa = XCBuildConfiguration;
171 | buildSettings = {
172 | ALWAYS_SEARCH_USER_PATHS = NO;
173 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
174 | CLANG_CXX_LIBRARY = "libc++";
175 | CLANG_ENABLE_MODULES = YES;
176 | CLANG_ENABLE_OBJC_ARC = YES;
177 | CLANG_WARN_BOOL_CONVERSION = YES;
178 | CLANG_WARN_CONSTANT_CONVERSION = YES;
179 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
180 | CLANG_WARN_EMPTY_BODY = YES;
181 | CLANG_WARN_ENUM_CONVERSION = YES;
182 | CLANG_WARN_INT_CONVERSION = YES;
183 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
184 | CLANG_WARN_UNREACHABLE_CODE = YES;
185 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
186 | "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
187 | COPY_PHASE_STRIP = NO;
188 | DEBUG_INFORMATION_FORMAT = dwarf;
189 | ENABLE_STRICT_OBJC_MSGSEND = YES;
190 | ENABLE_TESTABILITY = YES;
191 | GCC_C_LANGUAGE_STANDARD = gnu99;
192 | GCC_DYNAMIC_NO_PIC = NO;
193 | GCC_NO_COMMON_BLOCKS = YES;
194 | GCC_OPTIMIZATION_LEVEL = 0;
195 | GCC_PREPROCESSOR_DEFINITIONS = (
196 | "DEBUG=1",
197 | "$(inherited)",
198 | );
199 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
200 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
201 | GCC_WARN_UNDECLARED_SELECTOR = YES;
202 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
203 | GCC_WARN_UNUSED_FUNCTION = YES;
204 | GCC_WARN_UNUSED_VARIABLE = YES;
205 | IPHONEOS_DEPLOYMENT_TARGET = 9.1;
206 | MTL_ENABLE_DEBUG_INFO = YES;
207 | ONLY_ACTIVE_ARCH = YES;
208 | SDKROOT = iphoneos;
209 | SWIFT_OPTIMIZATION_LEVEL = "-Onone";
210 | TARGETED_DEVICE_FAMILY = 2;
211 | };
212 | name = Debug;
213 | };
214 | 3EC2AEB71C005567005CBE16 /* Release */ = {
215 | isa = XCBuildConfiguration;
216 | buildSettings = {
217 | ALWAYS_SEARCH_USER_PATHS = NO;
218 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
219 | CLANG_CXX_LIBRARY = "libc++";
220 | CLANG_ENABLE_MODULES = YES;
221 | CLANG_ENABLE_OBJC_ARC = YES;
222 | CLANG_WARN_BOOL_CONVERSION = YES;
223 | CLANG_WARN_CONSTANT_CONVERSION = YES;
224 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
225 | CLANG_WARN_EMPTY_BODY = YES;
226 | CLANG_WARN_ENUM_CONVERSION = YES;
227 | CLANG_WARN_INT_CONVERSION = YES;
228 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
229 | CLANG_WARN_UNREACHABLE_CODE = YES;
230 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
231 | "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
232 | COPY_PHASE_STRIP = NO;
233 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
234 | ENABLE_NS_ASSERTIONS = NO;
235 | ENABLE_STRICT_OBJC_MSGSEND = YES;
236 | GCC_C_LANGUAGE_STANDARD = gnu99;
237 | GCC_NO_COMMON_BLOCKS = YES;
238 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
239 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
240 | GCC_WARN_UNDECLARED_SELECTOR = YES;
241 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
242 | GCC_WARN_UNUSED_FUNCTION = YES;
243 | GCC_WARN_UNUSED_VARIABLE = YES;
244 | IPHONEOS_DEPLOYMENT_TARGET = 9.1;
245 | MTL_ENABLE_DEBUG_INFO = NO;
246 | SDKROOT = iphoneos;
247 | TARGETED_DEVICE_FAMILY = 2;
248 | VALIDATE_PRODUCT = YES;
249 | };
250 | name = Release;
251 | };
252 | 3EC2AEB91C005567005CBE16 /* Debug */ = {
253 | isa = XCBuildConfiguration;
254 | buildSettings = {
255 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
256 | INFOPLIST_FILE = PencilController/Info.plist;
257 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
258 | PRODUCT_BUNDLE_IDENTIFIER = uk.co.flexmonkey.PencilController;
259 | PRODUCT_NAME = "$(TARGET_NAME)";
260 | };
261 | name = Debug;
262 | };
263 | 3EC2AEBA1C005567005CBE16 /* Release */ = {
264 | isa = XCBuildConfiguration;
265 | buildSettings = {
266 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
267 | INFOPLIST_FILE = PencilController/Info.plist;
268 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
269 | PRODUCT_BUNDLE_IDENTIFIER = uk.co.flexmonkey.PencilController;
270 | PRODUCT_NAME = "$(TARGET_NAME)";
271 | };
272 | name = Release;
273 | };
274 | /* End XCBuildConfiguration section */
275 |
276 | /* Begin XCConfigurationList section */
277 | 3EC2AEA11C005567005CBE16 /* Build configuration list for PBXProject "PencilController" */ = {
278 | isa = XCConfigurationList;
279 | buildConfigurations = (
280 | 3EC2AEB61C005567005CBE16 /* Debug */,
281 | 3EC2AEB71C005567005CBE16 /* Release */,
282 | );
283 | defaultConfigurationIsVisible = 0;
284 | defaultConfigurationName = Release;
285 | };
286 | 3EC2AEB81C005567005CBE16 /* Build configuration list for PBXNativeTarget "PencilController" */ = {
287 | isa = XCConfigurationList;
288 | buildConfigurations = (
289 | 3EC2AEB91C005567005CBE16 /* Debug */,
290 | 3EC2AEBA1C005567005CBE16 /* Release */,
291 | );
292 | defaultConfigurationIsVisible = 0;
293 | };
294 | /* End XCConfigurationList section */
295 | };
296 | rootObject = 3EC2AE9E1C005567005CBE16 /* Project object */;
297 | }
298 |
--------------------------------------------------------------------------------
/PencilController/ViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ViewController.swift
3 | // PencilController
4 | //
5 | // Created by Simon Gladman on 21/11/2015.
6 | // Copyright © 2015 Simon Gladman. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import SceneKit
11 |
12 | class ViewController: UIViewController
13 | {
14 | let label = UILabel()
15 |
16 | let halfPi = CGFloat(M_PI_2)
17 | let pi = CGFloat(M_PI)
18 |
19 | let ciContext = CIContext(EAGLContext: EAGLContext(API: EAGLRenderingAPI.OpenGLES2),
20 | options: [kCIContextWorkingColorSpace: NSNull()])
21 |
22 | let coreImage = CIImage(image: UIImage(named: "DSCF0786.jpg")!)!
23 |
24 | let imageView = UIImageView()
25 |
26 | let sceneKitView = SCNView()
27 | let scene = SCNScene()
28 | let cylinderNode = SCNNode(geometry: SCNCapsule(capRadius: 0.05, height: 1))
29 | let plane = SCNNode(geometry: SCNPlane(width: 20, height: 20))
30 |
31 | let hueAdjust = CIFilter(name: "CIHueAdjust")!
32 | let colorControls = CIFilter(name: "CIColorControls")!
33 | let gammaAdjust = CIFilter(name: "CIGammaAdjust")!
34 | let exposureAdjust = CIFilter(name: "CIExposureAdjust")!
35 |
36 | let hueSaturationButton = ChunkyButton(title: "Hue\nSaturation", filteringMode: .HueSaturation)
37 | let brightnessContrastButton = ChunkyButton(title: "Brightness\nContrast", filteringMode: .BrightnessContrast)
38 | let gammaExposureButton = ChunkyButton(title: "Gamma\nExposure", filteringMode: .GammaExposure)
39 |
40 | var hueAngle: CGFloat = 0
41 | var saturation: CGFloat = 1
42 | var brightness: CGFloat = 0
43 | var contrast: CGFloat = 1
44 | var gamma: CGFloat = 1
45 | var exposure: CGFloat = 0
46 |
47 | var pencilOn = false
48 |
49 | var filteringMode = FilteringMode.Off
50 | {
51 | didSet
52 | {
53 | label.hidden = filteringMode == .Off
54 | }
55 | }
56 |
57 | override func viewDidLoad()
58 | {
59 | super.viewDidLoad()
60 |
61 | view.backgroundColor = UIColor.blackColor()
62 |
63 | view.addSubview(imageView)
64 | view.addSubview(label)
65 | view.addSubview(sceneKitView)
66 |
67 | view.addSubview(hueSaturationButton)
68 | view.addSubview(brightnessContrastButton)
69 | view.addSubview(gammaExposureButton)
70 |
71 | label.font = UIFont.monospacedDigitSystemFontOfSize(36, weight: UIFontWeightSemibold)
72 |
73 | label.textAlignment = NSTextAlignment.Center
74 | label.text = "flexmonkey.blogspot.co.uk"
75 | label.textColor = UIColor.whiteColor()
76 | label.hidden = true
77 |
78 | imageView.contentMode = UIViewContentMode.Center
79 |
80 | sceneKitView.scene = scene
81 | sceneKitView.backgroundColor = UIColor.clearColor()
82 | addLights()
83 |
84 | let camera = SCNCamera()
85 | camera.usesOrthographicProjection = true
86 |
87 | camera.xFov = 45
88 | camera.yFov = 45
89 |
90 | let cameraNode = SCNNode()
91 |
92 | cameraNode.camera = camera
93 | cameraNode.position = SCNVector3(x: 0, y: 0, z: 20)
94 |
95 | cylinderNode.position = SCNVector3(0, 0, 0)
96 | cylinderNode.pivot = SCNMatrix4MakeTranslation(0, 0.5, 0)
97 |
98 | plane.opacity = 0.000001
99 |
100 | scene.rootNode.addChildNode(cameraNode)
101 | scene.rootNode.addChildNode(cylinderNode)
102 | scene.rootNode.addChildNode(plane)
103 |
104 | cylinderNode.opacity = 0
105 |
106 | applyFilter()
107 |
108 | hueSaturationButton.addTarget(self, action: "filterButtonTouchDown:", forControlEvents: UIControlEvents.TouchDown)
109 | hueSaturationButton.addTarget(self, action: "filterButtonTouchEnded:", forControlEvents: UIControlEvents.TouchUpInside)
110 |
111 | brightnessContrastButton.addTarget(self, action: "filterButtonTouchDown:", forControlEvents: UIControlEvents.TouchDown)
112 | brightnessContrastButton.addTarget(self, action: "filterButtonTouchEnded:", forControlEvents: UIControlEvents.TouchUpInside)
113 |
114 | gammaExposureButton.addTarget(self, action: "filterButtonTouchDown:", forControlEvents: UIControlEvents.TouchDown)
115 | gammaExposureButton.addTarget(self, action: "filterButtonTouchEnded:", forControlEvents: UIControlEvents.TouchUpInside)
116 | }
117 |
118 | func filterButtonTouchDown(button: ChunkyButton)
119 | {
120 | filteringMode = button.filteringMode
121 |
122 | updateLabel()
123 |
124 | if pencilOn
125 | {
126 | SCNTransaction.setAnimationDuration(0.25)
127 | cylinderNode.opacity = 1
128 | }
129 | }
130 |
131 | func filterButtonTouchEnded(button: ChunkyButton)
132 | {
133 | filteringMode = .Off
134 |
135 | SCNTransaction.setAnimationDuration(0.25)
136 | cylinderNode.opacity = 0
137 | }
138 |
139 | override func touchesBegan(touches: Set, withEvent event: UIEvent?)
140 | {
141 | guard let touch = touches.first where
142 | filteringMode != .Off &&
143 | touch.type == UITouchType.Stylus else
144 | {
145 | return
146 | }
147 |
148 | pencilOn = true
149 |
150 | pencilTouchHandler(touch)
151 |
152 | SCNTransaction.setAnimationDuration(0.25)
153 | cylinderNode.opacity = 1
154 | }
155 |
156 | override func touchesMoved(touches: Set, withEvent event: UIEvent?)
157 | {
158 | guard let touch = touches.first where
159 | filteringMode != .Off &&
160 | touch.type == UITouchType.Stylus else
161 | {
162 | return
163 | }
164 |
165 | pencilTouchHandler(touch)
166 | }
167 |
168 | override func touchesEnded(touches: Set, withEvent event: UIEvent?)
169 | {
170 | guard touches.first?.type == UITouchType.Stylus else
171 | {
172 | return
173 | }
174 |
175 | pencilOn = false
176 | SCNTransaction.setAnimationDuration(0.25)
177 | cylinderNode.opacity = 0
178 | }
179 |
180 | func pencilTouchHandler(touch: UITouch)
181 | {
182 | guard let hitTestResult:SCNHitTestResult = sceneKitView.hitTest(touch.locationInView(view), options: nil).filter( { $0.node == plane }).first else
183 | {
184 | return
185 | }
186 |
187 | SCNTransaction.setAnimationDuration(0)
188 |
189 | cylinderNode.position = SCNVector3(hitTestResult.localCoordinates.x, hitTestResult.localCoordinates.y, 0)
190 | cylinderNode.eulerAngles = SCNVector3(touch.altitudeAngle, 0.0, 0 - touch.azimuthAngleInView(view) - halfPi)
191 |
192 | switch filteringMode
193 | {
194 | case .HueSaturation:
195 | hueAngle = pi + touch.azimuthAngleInView(view)
196 | saturation = 8 * ((halfPi - touch.altitudeAngle) / halfPi)
197 |
198 | case .BrightnessContrast:
199 | brightness = touch.azimuthUnitVectorInView(view).dx * ((halfPi - touch.altitudeAngle) / halfPi)
200 | contrast = 1 + touch.azimuthUnitVectorInView(view).dy * -((halfPi - touch.altitudeAngle) / halfPi)
201 |
202 | case .GammaExposure:
203 | gamma = 1 + touch.azimuthUnitVectorInView(view).dx * ((halfPi - touch.altitudeAngle) / halfPi)
204 | exposure = touch.azimuthUnitVectorInView(view).dy * -((halfPi - touch.altitudeAngle) / halfPi)
205 |
206 | case .Off:
207 | ()
208 | }
209 |
210 | updateLabel()
211 | applyFilter()
212 | }
213 |
214 |
215 |
216 | func applyFilter()
217 | {
218 | hueAdjust.setValue(coreImage,
219 | forKey: kCIInputImageKey)
220 | hueAdjust.setValue(hueAngle,
221 | forKey: kCIInputAngleKey)
222 |
223 | colorControls.setValue(hueAdjust.valueForKey(kCIOutputImageKey) as! CIImage,
224 | forKey: kCIInputImageKey)
225 | colorControls.setValue(saturation,
226 | forKey: kCIInputSaturationKey)
227 | colorControls.setValue(brightness,
228 | forKey: kCIInputBrightnessKey)
229 | colorControls.setValue(contrast,
230 | forKey: kCIInputContrastKey)
231 |
232 | exposureAdjust.setValue(colorControls.valueForKey(kCIOutputImageKey) as! CIImage,
233 | forKey: kCIInputImageKey)
234 | exposureAdjust.setValue(exposure,
235 | forKey: kCIInputEVKey)
236 |
237 | gammaAdjust.setValue(exposureAdjust.valueForKey(kCIOutputImageKey) as! CIImage,
238 | forKey: kCIInputImageKey)
239 | gammaAdjust.setValue(gamma,
240 | forKey: "inputPower")
241 |
242 |
243 | let cgImage = ciContext.createCGImage(gammaAdjust.valueForKey(kCIOutputImageKey) as! CIImage,
244 | fromRect: coreImage.extent)
245 |
246 | imageView.image = UIImage(CGImage: cgImage)
247 | }
248 |
249 | func updateLabel()
250 | {
251 | switch filteringMode
252 | {
253 | case .HueSaturation:
254 | label.text = String(format: "↻Hue: %.2f°", hueAngle * 180 / pi) + " " + String(format: "∢Saturation: %.2f", saturation)
255 |
256 | case .BrightnessContrast:
257 | label.text = String(format: "⇔Brightness: %.2f", brightness) + " " + String(format: "⇕Contrast: %.2f", contrast)
258 |
259 | case .GammaExposure:
260 | label.text = String(format: "⇔Gamma: %.2f", gamma) + " " + String(format: "⇕Exposure: %.2f", exposure)
261 |
262 | case .Off:
263 | ()
264 | }
265 | }
266 |
267 | func addLights()
268 | {
269 | // ambient light...
270 |
271 | let ambientLight = SCNLight()
272 | ambientLight.type = SCNLightTypeAmbient
273 | ambientLight.color = UIColor(white: 0.15, alpha: 1.0)
274 | let ambientLightNode = SCNNode()
275 | ambientLightNode.light = ambientLight
276 |
277 | scene.rootNode.addChildNode(ambientLightNode)
278 |
279 | // omni light...
280 |
281 | let omniLight = SCNLight()
282 | omniLight.type = SCNLightTypeOmni
283 | omniLight.color = UIColor(white: 1.0, alpha: 1.0)
284 | let omniLightNode = SCNNode()
285 | omniLightNode.light = omniLight
286 | omniLightNode.position = SCNVector3(x: -10, y: 10, z: 30)
287 |
288 | scene.rootNode.addChildNode(omniLightNode)
289 | }
290 |
291 | override func viewDidLayoutSubviews()
292 | {
293 | label.frame = CGRect(x: 0,
294 | y: topLayoutGuide.length,
295 | width: view.frame.width,
296 | height: label.intrinsicContentSize().height)
297 |
298 | imageView.frame = view.bounds
299 | sceneKitView.frame = view.bounds
300 |
301 | // Slightly cobbled together layout :)
302 |
303 | hueSaturationButton.frame = CGRect(x: 0,
304 | y: view.frame.height - hueSaturationButton.intrinsicContentSize().height,
305 | width: hueSaturationButton.intrinsicContentSize().width,
306 | height: hueSaturationButton.intrinsicContentSize().height)
307 |
308 | brightnessContrastButton.frame = CGRect(x: hueSaturationButton.intrinsicContentSize().width + 20,
309 | y: view.frame.height - hueSaturationButton.intrinsicContentSize().height,
310 | width: hueSaturationButton.intrinsicContentSize().width,
311 | height: hueSaturationButton.intrinsicContentSize().height)
312 |
313 | gammaExposureButton.frame = CGRect(x: hueSaturationButton.intrinsicContentSize().width + 20 + hueSaturationButton.intrinsicContentSize().width + 20,
314 | y: view.frame.height - hueSaturationButton.intrinsicContentSize().height,
315 | width: hueSaturationButton.intrinsicContentSize().width,
316 | height: hueSaturationButton.intrinsicContentSize().height)
317 | }
318 |
319 | }
320 |
321 | enum FilteringMode
322 | {
323 | case Off
324 | case HueSaturation
325 | case BrightnessContrast
326 | case GammaExposure
327 | }
328 |
329 | class ChunkyButton: UIButton
330 | {
331 | let defaultColor = UIColor(red: 0.25, green: 0.25, blue: 0.75, alpha: 0.5)
332 | let highlightedColor = UIColor(red: 0.25, green: 0.25, blue: 0.75, alpha: 1)
333 |
334 | let filteringMode: FilteringMode
335 |
336 | required init(title: String, filteringMode: FilteringMode)
337 | {
338 | self.filteringMode = filteringMode
339 |
340 | super.init(frame: CGRectZero)
341 |
342 | titleLabel?.numberOfLines = 2
343 | setTitle(title, forState: UIControlState.Normal)
344 | titleLabel?.font = UIFont.boldSystemFontOfSize(24)
345 |
346 | backgroundColor = defaultColor
347 | setTitleColor(UIColor.whiteColor(), forState: UIControlState.Highlighted)
348 | setTitleColor(UIColor.lightGrayColor(), forState: UIControlState.Normal)
349 |
350 | layer.borderColor = UIColor.whiteColor().CGColor
351 | layer.borderWidth = 2
352 | layer.cornerRadius = 5
353 | }
354 |
355 | override var highlighted: Bool
356 | {
357 | didSet
358 | {
359 | backgroundColor = highlighted ? highlightedColor : defaultColor
360 | }
361 | }
362 |
363 | required init?(coder aDecoder: NSCoder)
364 | {
365 | fatalError("init(coder:) has not been implemented")
366 | }
367 |
368 | override func intrinsicContentSize() -> CGSize
369 | {
370 | return CGSize(width: super.intrinsicContentSize().width + 20,
371 | height: super.intrinsicContentSize().height + 10)
372 | }
373 | }
374 |
375 |
--------------------------------------------------------------------------------