├── LICENSE.md ├── README.md ├── dist └── gestures.js ├── gesture-detector.js ├── gesture-handler.js ├── image-tracking.html ├── index.html └── styles.css /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

AR.js & A-Frame Gestures

2 | 3 |

gesture sample

4 | 5 | Example of using gesture events on AR.js with A-Frame. This work is based on [this example](https://github.com/8thwall/web/blob/master/examples/aframe/manipulate/README.md) from 8th Wall. 6 | 7 | Scale and rotate 3D elements from your AR.js scene using `gesture-detector` and `gesture-handler` components. 8 | 9 | 10 | ## Try now! 11 | 12 | #### Image Tracking 13 | 14 | 🚀[Open this sample](https://fcor.github.io/arjs-gestures/image-tracking.html) on your phone and [scan this picture](https://raw.githubusercontent.com/AR-js-org/AR.js/master/aframe/examples/image-tracking/nft/trex-image-big.jpeg) 15 | 16 | #### Marker Tracking 17 | 18 | 🚀[Open this sample](https://fcor.github.io/arjs-gestures/index.html) on your phone and [scan this marker](https://killcloud.nyc3.digitaloceanspaces.com/assets/Hiro_marker_ARjs.png) 19 | 20 | ## Installation 21 | 22 | Import this file if you want default touch events. Keep reading to learn how to extend it. 23 | 24 | ```html 25 | 26 | ``` 27 | 28 | ## How it works? 29 | 30 | `gesture-detector` listens to regular touch events directly on `a-scene` and emits a custom event indicating how many fingers were involved ("one", "two", "three" or "many") and passing some details of the event, like the position, spread and coordinates where user touched the screen. This component was developed by 8th Wall for their A-Frame based demos and can be found [here](https://github.com/8thwall/web/blob/master/examples/aframe/manipulate/gesture-detector.js). 31 | 32 | `gesture-handler` adds listeners for custom gesture events, emitted by `gesture-detector`. This component should be placed on the 3D element we want to control and it automaticaly detects if the marker or image is found or lost to ensure the element could only be manipulated if it's actually visible. This component could be customized via properties. Currently supports pinch to zoom and finger spin for rotating the element. 33 | 34 | ### Properties 35 | 36 | | Property | Description | Default Value | 37 | | -------------- | --------------------------------------- | ------------- | 38 | | enabled | Whether gesture controls are enabled. | true | 39 | | rotationFactor | Factor for controlling rotation | 5 | 40 | | minScale | Minimum scale applied to the 3D element | 0.3 | 41 | | maxScale | Minimum scale applied to the 3D element | 8 | 42 | 43 | ## Examples 44 | 45 | #### Image Tracking 46 | 47 | ```html 48 | 56 | 67 | 74 | 75 | 76 | 77 | 78 | ``` 79 | 80 | #### Marker Tracking 81 | 82 | ```html 83 | 91 | 92 | 96 | 97 | 98 | 99 | 106 | 114 | 115 | 116 | 117 | 118 | ``` 119 | 120 | ## Credits 121 | Kudos to 8th wall for sharing their A-Frame Manipulate example! 122 | 123 | Bowser 3D model was made by [santiago3052008](https://sketchfab.com/santiago3052008) and can be found [here](https://sketchfab.com/3d-models/bowser-fa17f94ae350416f86c35db7c0e129c3) -------------------------------------------------------------------------------- /dist/gestures.js: -------------------------------------------------------------------------------- 1 | /* global AFRAME, THREE */ 2 | 3 | AFRAME.registerComponent("gesture-handler", { 4 | schema: { 5 | enabled: { default: true }, 6 | rotationFactor: { default: 5 }, 7 | minScale: { default: 0.3 }, 8 | maxScale: { default: 8 }, 9 | }, 10 | 11 | init: function () { 12 | this.handleScale = this.handleScale.bind(this); 13 | this.handleRotation = this.handleRotation.bind(this); 14 | 15 | this.isVisible = false; 16 | this.initialScale = this.el.object3D.scale.clone(); 17 | this.scaleFactor = 1; 18 | 19 | this.el.sceneEl.addEventListener("markerFound", (e) => { 20 | this.isVisible = true; 21 | }); 22 | 23 | this.el.sceneEl.addEventListener("markerLost", (e) => { 24 | this.isVisible = false; 25 | }); 26 | }, 27 | 28 | update: function () { 29 | if (this.data.enabled) { 30 | this.el.sceneEl.addEventListener("onefingermove", this.handleRotation); 31 | this.el.sceneEl.addEventListener("twofingermove", this.handleScale); 32 | } else { 33 | this.el.sceneEl.removeEventListener("onefingermove", this.handleRotation); 34 | this.el.sceneEl.removeEventListener("twofingermove", this.handleScale); 35 | } 36 | }, 37 | 38 | remove: function () { 39 | this.el.sceneEl.removeEventListener("onefingermove", this.handleRotation); 40 | this.el.sceneEl.removeEventListener("twofingermove", this.handleScale); 41 | }, 42 | 43 | handleRotation: function (event) { 44 | if (this.isVisible) { 45 | this.el.object3D.rotation.y += 46 | event.detail.positionChange.x * this.data.rotationFactor; 47 | this.el.object3D.rotation.x += 48 | event.detail.positionChange.y * this.data.rotationFactor; 49 | } 50 | }, 51 | 52 | handleScale: function (event) { 53 | if (this.isVisible) { 54 | this.scaleFactor *= 55 | 1 + event.detail.spreadChange / event.detail.startSpread; 56 | 57 | this.scaleFactor = Math.min( 58 | Math.max(this.scaleFactor, this.data.minScale), 59 | this.data.maxScale 60 | ); 61 | 62 | this.el.object3D.scale.x = this.scaleFactor * this.initialScale.x; 63 | this.el.object3D.scale.y = this.scaleFactor * this.initialScale.y; 64 | this.el.object3D.scale.z = this.scaleFactor * this.initialScale.z; 65 | } 66 | }, 67 | }); 68 | 69 | // Component that detects and emits events for touch gestures 70 | 71 | AFRAME.registerComponent("gesture-detector", { 72 | schema: { 73 | element: { default: "" } 74 | }, 75 | 76 | init: function() { 77 | this.targetElement = 78 | this.data.element && document.querySelector(this.data.element); 79 | 80 | if (!this.targetElement) { 81 | this.targetElement = this.el; 82 | } 83 | 84 | this.internalState = { 85 | previousState: null 86 | }; 87 | 88 | this.emitGestureEvent = this.emitGestureEvent.bind(this); 89 | 90 | this.targetElement.addEventListener("touchstart", this.emitGestureEvent); 91 | 92 | this.targetElement.addEventListener("touchend", this.emitGestureEvent); 93 | 94 | this.targetElement.addEventListener("touchmove", this.emitGestureEvent); 95 | }, 96 | 97 | remove: function() { 98 | this.targetElement.removeEventListener("touchstart", this.emitGestureEvent); 99 | 100 | this.targetElement.removeEventListener("touchend", this.emitGestureEvent); 101 | 102 | this.targetElement.removeEventListener("touchmove", this.emitGestureEvent); 103 | }, 104 | 105 | emitGestureEvent(event) { 106 | const currentState = this.getTouchState(event); 107 | 108 | const previousState = this.internalState.previousState; 109 | 110 | const gestureContinues = 111 | previousState && 112 | currentState && 113 | currentState.touchCount == previousState.touchCount; 114 | 115 | const gestureEnded = previousState && !gestureContinues; 116 | 117 | const gestureStarted = currentState && !gestureContinues; 118 | 119 | if (gestureEnded) { 120 | const eventName = 121 | this.getEventPrefix(previousState.touchCount) + "fingerend"; 122 | 123 | this.el.emit(eventName, previousState); 124 | 125 | this.internalState.previousState = null; 126 | } 127 | 128 | if (gestureStarted) { 129 | currentState.startTime = performance.now(); 130 | 131 | currentState.startPosition = currentState.position; 132 | 133 | currentState.startSpread = currentState.spread; 134 | 135 | const eventName = 136 | this.getEventPrefix(currentState.touchCount) + "fingerstart"; 137 | 138 | this.el.emit(eventName, currentState); 139 | 140 | this.internalState.previousState = currentState; 141 | } 142 | 143 | if (gestureContinues) { 144 | const eventDetail = { 145 | positionChange: { 146 | x: currentState.position.x - previousState.position.x, 147 | 148 | y: currentState.position.y - previousState.position.y 149 | } 150 | }; 151 | 152 | if (currentState.spread) { 153 | eventDetail.spreadChange = currentState.spread - previousState.spread; 154 | } 155 | 156 | // Update state with new data 157 | 158 | Object.assign(previousState, currentState); 159 | 160 | // Add state data to event detail 161 | 162 | Object.assign(eventDetail, previousState); 163 | 164 | const eventName = 165 | this.getEventPrefix(currentState.touchCount) + "fingermove"; 166 | 167 | this.el.emit(eventName, eventDetail); 168 | } 169 | }, 170 | 171 | getTouchState: function(event) { 172 | if (event.touches.length === 0) { 173 | return null; 174 | } 175 | 176 | // Convert event.touches to an array so we can use reduce 177 | 178 | const touchList = []; 179 | 180 | for (let i = 0; i < event.touches.length; i++) { 181 | touchList.push(event.touches[i]); 182 | } 183 | 184 | const touchState = { 185 | touchCount: touchList.length 186 | }; 187 | 188 | // Calculate center of all current touches 189 | 190 | const centerPositionRawX = 191 | touchList.reduce((sum, touch) => sum + touch.clientX, 0) / 192 | touchList.length; 193 | 194 | const centerPositionRawY = 195 | touchList.reduce((sum, touch) => sum + touch.clientY, 0) / 196 | touchList.length; 197 | 198 | touchState.positionRaw = { x: centerPositionRawX, y: centerPositionRawY }; 199 | 200 | // Scale touch position and spread by average of window dimensions 201 | 202 | const screenScale = 2 / (window.innerWidth + window.innerHeight); 203 | 204 | touchState.position = { 205 | x: centerPositionRawX * screenScale, 206 | y: centerPositionRawY * screenScale 207 | }; 208 | 209 | // Calculate average spread of touches from the center point 210 | 211 | if (touchList.length >= 2) { 212 | const spread = 213 | touchList.reduce((sum, touch) => { 214 | return ( 215 | sum + 216 | Math.sqrt( 217 | Math.pow(centerPositionRawX - touch.clientX, 2) + 218 | Math.pow(centerPositionRawY - touch.clientY, 2) 219 | ) 220 | ); 221 | }, 0) / touchList.length; 222 | 223 | touchState.spread = spread * screenScale; 224 | } 225 | 226 | return touchState; 227 | }, 228 | 229 | getEventPrefix(touchCount) { 230 | const numberNames = ["one", "two", "three", "many"]; 231 | 232 | return numberNames[Math.min(touchCount, 4) - 1]; 233 | } 234 | }); 235 | -------------------------------------------------------------------------------- /gesture-detector.js: -------------------------------------------------------------------------------- 1 | // Component that detects and emits events for touch gestures 2 | 3 | AFRAME.registerComponent("gesture-detector", { 4 | schema: { 5 | element: { default: "" } 6 | }, 7 | 8 | init: function() { 9 | this.targetElement = 10 | this.data.element && document.querySelector(this.data.element); 11 | 12 | if (!this.targetElement) { 13 | this.targetElement = this.el; 14 | } 15 | 16 | this.internalState = { 17 | previousState: null 18 | }; 19 | 20 | this.emitGestureEvent = this.emitGestureEvent.bind(this); 21 | 22 | this.targetElement.addEventListener("touchstart", this.emitGestureEvent); 23 | 24 | this.targetElement.addEventListener("touchend", this.emitGestureEvent); 25 | 26 | this.targetElement.addEventListener("touchmove", this.emitGestureEvent); 27 | }, 28 | 29 | remove: function() { 30 | this.targetElement.removeEventListener("touchstart", this.emitGestureEvent); 31 | 32 | this.targetElement.removeEventListener("touchend", this.emitGestureEvent); 33 | 34 | this.targetElement.removeEventListener("touchmove", this.emitGestureEvent); 35 | }, 36 | 37 | emitGestureEvent(event) { 38 | const currentState = this.getTouchState(event); 39 | 40 | const previousState = this.internalState.previousState; 41 | 42 | const gestureContinues = 43 | previousState && 44 | currentState && 45 | currentState.touchCount == previousState.touchCount; 46 | 47 | const gestureEnded = previousState && !gestureContinues; 48 | 49 | const gestureStarted = currentState && !gestureContinues; 50 | 51 | if (gestureEnded) { 52 | const eventName = 53 | this.getEventPrefix(previousState.touchCount) + "fingerend"; 54 | 55 | this.el.emit(eventName, previousState); 56 | 57 | this.internalState.previousState = null; 58 | } 59 | 60 | if (gestureStarted) { 61 | currentState.startTime = performance.now(); 62 | 63 | currentState.startPosition = currentState.position; 64 | 65 | currentState.startSpread = currentState.spread; 66 | 67 | const eventName = 68 | this.getEventPrefix(currentState.touchCount) + "fingerstart"; 69 | 70 | this.el.emit(eventName, currentState); 71 | 72 | this.internalState.previousState = currentState; 73 | } 74 | 75 | if (gestureContinues) { 76 | const eventDetail = { 77 | positionChange: { 78 | x: currentState.position.x - previousState.position.x, 79 | 80 | y: currentState.position.y - previousState.position.y 81 | } 82 | }; 83 | 84 | if (currentState.spread) { 85 | eventDetail.spreadChange = currentState.spread - previousState.spread; 86 | } 87 | 88 | // Update state with new data 89 | 90 | Object.assign(previousState, currentState); 91 | 92 | // Add state data to event detail 93 | 94 | Object.assign(eventDetail, previousState); 95 | 96 | const eventName = 97 | this.getEventPrefix(currentState.touchCount) + "fingermove"; 98 | 99 | this.el.emit(eventName, eventDetail); 100 | } 101 | }, 102 | 103 | getTouchState: function(event) { 104 | if (event.touches.length === 0) { 105 | return null; 106 | } 107 | 108 | // Convert event.touches to an array so we can use reduce 109 | 110 | const touchList = []; 111 | 112 | for (let i = 0; i < event.touches.length; i++) { 113 | touchList.push(event.touches[i]); 114 | } 115 | 116 | const touchState = { 117 | touchCount: touchList.length 118 | }; 119 | 120 | // Calculate center of all current touches 121 | 122 | const centerPositionRawX = 123 | touchList.reduce((sum, touch) => sum + touch.clientX, 0) / 124 | touchList.length; 125 | 126 | const centerPositionRawY = 127 | touchList.reduce((sum, touch) => sum + touch.clientY, 0) / 128 | touchList.length; 129 | 130 | touchState.positionRaw = { x: centerPositionRawX, y: centerPositionRawY }; 131 | 132 | // Scale touch position and spread by average of window dimensions 133 | 134 | const screenScale = 2 / (window.innerWidth + window.innerHeight); 135 | 136 | touchState.position = { 137 | x: centerPositionRawX * screenScale, 138 | y: centerPositionRawY * screenScale 139 | }; 140 | 141 | // Calculate average spread of touches from the center point 142 | 143 | if (touchList.length >= 2) { 144 | const spread = 145 | touchList.reduce((sum, touch) => { 146 | return ( 147 | sum + 148 | Math.sqrt( 149 | Math.pow(centerPositionRawX - touch.clientX, 2) + 150 | Math.pow(centerPositionRawY - touch.clientY, 2) 151 | ) 152 | ); 153 | }, 0) / touchList.length; 154 | 155 | touchState.spread = spread * screenScale; 156 | } 157 | 158 | return touchState; 159 | }, 160 | 161 | getEventPrefix(touchCount) { 162 | const numberNames = ["one", "two", "three", "many"]; 163 | 164 | return numberNames[Math.min(touchCount, 4) - 1]; 165 | } 166 | }); 167 | -------------------------------------------------------------------------------- /gesture-handler.js: -------------------------------------------------------------------------------- 1 | /* global AFRAME, THREE */ 2 | 3 | AFRAME.registerComponent("gesture-handler", { 4 | schema: { 5 | enabled: { default: true }, 6 | rotationFactor: { default: 5 }, 7 | minScale: { default: 0.3 }, 8 | maxScale: { default: 8 }, 9 | }, 10 | 11 | init: function () { 12 | this.handleScale = this.handleScale.bind(this); 13 | this.handleRotation = this.handleRotation.bind(this); 14 | 15 | this.isVisible = false; 16 | this.initialScale = this.el.object3D.scale.clone(); 17 | this.scaleFactor = 1; 18 | 19 | this.el.sceneEl.addEventListener("markerFound", (e) => { 20 | this.isVisible = true; 21 | }); 22 | 23 | this.el.sceneEl.addEventListener("markerLost", (e) => { 24 | this.isVisible = false; 25 | }); 26 | }, 27 | 28 | update: function () { 29 | if (this.data.enabled) { 30 | this.el.sceneEl.addEventListener("onefingermove", this.handleRotation); 31 | this.el.sceneEl.addEventListener("twofingermove", this.handleScale); 32 | } else { 33 | this.el.sceneEl.removeEventListener("onefingermove", this.handleRotation); 34 | this.el.sceneEl.removeEventListener("twofingermove", this.handleScale); 35 | } 36 | }, 37 | 38 | remove: function () { 39 | this.el.sceneEl.removeEventListener("onefingermove", this.handleRotation); 40 | this.el.sceneEl.removeEventListener("twofingermove", this.handleScale); 41 | }, 42 | 43 | handleRotation: function (event) { 44 | if (this.isVisible) { 45 | this.el.object3D.rotation.y += 46 | event.detail.positionChange.x * this.data.rotationFactor; 47 | this.el.object3D.rotation.x += 48 | event.detail.positionChange.y * this.data.rotationFactor; 49 | } 50 | }, 51 | 52 | handleScale: function (event) { 53 | if (this.isVisible) { 54 | this.scaleFactor *= 55 | 1 + event.detail.spreadChange / event.detail.startSpread; 56 | 57 | this.scaleFactor = Math.min( 58 | Math.max(this.scaleFactor, this.data.minScale), 59 | this.data.maxScale 60 | ); 61 | 62 | this.el.object3D.scale.x = this.scaleFactor * this.initialScale.x; 63 | this.el.object3D.scale.y = this.scaleFactor * this.initialScale.y; 64 | this.el.object3D.scale.z = this.scaleFactor * this.initialScale.z; 65 | } 66 | }, 67 | }); 68 | -------------------------------------------------------------------------------- /image-tracking.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Gesture Interactions - A-Frame & AR.js 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 25 | 36 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Gesture Interactions - A-Frame & AR.js 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 25 | 26 | 30 | 31 | 32 | 33 | 40 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0px; 3 | overflow: hidden; 4 | } --------------------------------------------------------------------------------