diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e2732d9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+gltumble.js
+gltumble.min.js
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..21c0720
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2018 Philip Rideout
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b2f9b46
--- /dev/null
+++ b/README.md
@@ -0,0 +1,69 @@
+
gltumble
+
+[![badge]](https://travis-ci.org/prideout/gltumble)
+
+This library provides a math class called `Trackball` that allows users to spin (or "tumble") an
+object by dragging with a mouse, trackpad, or touch screen. It optionally applies inertia such that
+the object continues to spin if you flick it.
+
+The trackball does not do anything with quaternions. It simply applies Y rotation followed by X
+rotation. It avoids rotation about the Z axis, and here's why:
+
+**When I'm at a museum, I often walk around an *objet d'art*, or stand on my tiptoes to see its top,
+or crouch to see its underside. However, I very rarely tilt my head to the side, since that doesn't
+reveal anything new. The only times I tilt my head are when I'm expressing confusion, or trying to
+read vertical text.**
+
+Personally, I like this constraint. If you find it to be too limiting, try another package such
+as [trackball-controller].
+
+- [Interactive Demo] using [Filament] and WebGL 2.0
+- The demo [source code] is a single JS file.
+
+Note that `gltumble` emulates the behavior used by [sketchfab.com].
+
+## Example
+
+```js
+const trackball = new Trackball({foo: bar});
+const mat = trackball.getMatrix();
+trackball.execute();
+console.info(`The first component has ${nverts} vertices.`);
+```
+
+## Install
+
+Install with NPM (`npm install gltumble`) or Yarn (`yarn add gltumble`), then:
+
+```js
+import Trackball from 'gltumble';
+```
+
+Or use one of the following two CDN builds.
+
+```html
+
+
+```
+
+## API Reference
+
+#### new Trackball(options)
+
+Constructs a trackball, given an optional configuration dictionary.
+
+#### trackball.touch(...)
+
+TBD.
+
+#### trackball.getTransform(...)
+
+TBD.
+
+[badge]: https://travis-ci.org/prideout/.svg?branch=master "Build Status"
+[glMatrix]: http://glmatrix.net
+[Interactive Demo]: https://prideout.net/gltumble
+[Filament]: https://github.com/google/filament
+[source code]: https://github.com/prideout/knotess/blob/master/docs/demo.js
+[trackball-controller]: https://github.com/wwwtyro/trackball-controller
+[sketchfab.com]: https://sketchfab.com/models/bde956d410d4483da4126f1b0c80a06b
diff --git a/docs/Turntable.js b/docs/Turntable.js
new file mode 100644
index 0000000..9d055ae
--- /dev/null
+++ b/docs/Turntable.js
@@ -0,0 +1,180 @@
+var GIZA = GIZA || {};
+
+// Restricts rotation to Spin (Y axis) and Tilt (X axis)
+GIZA.Turntable = function(config) {
+
+ var M3 = GIZA.Matrix3;
+ var V3 = GIZA.Vector3;
+ var V2 = GIZA.Vector2;
+
+ // Allow clients to skip the "new"
+ if (!(this instanceof GIZA.Turntable)) {
+ return new GIZA.Turntable(config);
+ }
+
+ var defaults = {
+ startSpin: 0.001, // radians per second
+ allowTilt: true,
+ allowSpin: true,
+ spinFriction: 0.125, // 0 means no friction (infinite spin) while 1 means no inertia
+ epsilon: 3, // distance (in pixels) to wait before deciding if a drag is a Tilt or a Spin
+ radiansPerPixel: V2.make(0.01, 0.01),
+ canvas: GIZA.canvas,
+ trackpad: true, // if true, compensate for the delay on trackpads that occur between touchup and mouseup
+ lockAxes: false, // if true, don't allow simultaneous spin + tilt
+ homeTilt: 0.25,
+ //bounceTilt: false, // if true, returns the tilt to the "home" angle after a mouse release
+ //boundSpin: false, // if true, returns to the startSpin state after a mouse release
+ };
+
+ this.config = config = GIZA.merge(defaults, config || {});
+
+ // diagram please!
+ var state = {
+ Resting: 0,
+ SpinningStart: 1,
+ SpinningInertia: 2,
+ DraggingInit: 3,
+ DraggingSpin: 4,
+ DraggingTilt: 5,
+ ReturningHome: 6,
+ };
+
+ var startPosition = V2.make(0, 0);
+ var currentPosition = V2.make(0, 0);
+
+ // TODO make these into a "positionHistory"
+ var previousPosition = currentPosition.slice(0);
+ var previous2Position = currentPosition.slice(0);
+
+ var currentSpin = 0;
+ var currentTilt = config.homeTilt;
+ var currentState = config.startSpin ?
+ state.SpinningStart : state.Resting;
+ var previousTime = null;
+ var inertiaSpeed = 0;
+ var initialInertia = 0.125;
+ var turntable = this;
+
+ GIZA.mousedown(function(position, modifiers) {
+ turntable.startDrag(position);
+ });
+
+ GIZA.mouseup(function(position, modifiers) {
+ turntable.endDrag(position);
+ });
+
+ GIZA.mousemove(function(position, modifiers) {
+ if (modifiers.button) {
+ turntable.updateDrag(position);
+ }
+ });
+
+ GIZA.drawHooks.push(function(time) {
+ if (previousTime == null) {
+ previousTime = time;
+ }
+ var deltaTime = time - previousTime;
+ previousTime = time;
+
+ var isSpinning = currentState == state.DraggingSpin ||
+ (currentState == state.DraggingInit && !config.lockAxes);
+
+ if (currentState == state.SpinningStart) {
+ currentSpin += config.startSpin * deltaTime;
+ } else if (currentState == state.SpinningInertia) {
+ currentSpin += inertiaSpeed * deltaTime;
+ inertiaSpeed *= (1 - config.spinFriction);
+ if (Math.abs(inertiaSpeed) < 0.0001) {
+ currentState = state.Resting;
+ }
+
+ // Some trackpads have an intentional delay between fingers-up
+ // and the time we receive the mouseup event. To accomodate this,
+ // we execute inertia even while we think the mouse is still down.
+ // This behavior can be disabled with the "trackpad" config option.
+ } else if (config.trackpad && isSpinning &&
+ V2.equivalent(currentPosition, previous2Position, 0)) {
+ currentSpin += inertiaSpeed * deltaTime;
+ inertiaSpeed *= (1 - config.spinFriction);
+ }
+
+ previous2Position = previousPosition.slice(0);
+ previousPosition = currentPosition.slice(0);
+ });
+
+ this.startDrag = function(position) {
+ startPosition = position.slice(0);
+ currentPosition = position.slice(0);
+ currentState = state.DraggingInit;
+ };
+
+ this.updateDrag = function(position) {
+ var delta = V2.subtract(position, startPosition);
+
+ // If we haven't decided yet, decide if we're spinning or tilting.
+ if (currentState == state.DraggingInit && config.lockAxes) {
+ if (Math.abs(delta[0]) > config.epsilon && config.allowSpin) {
+ currentState = state.DraggingSpin;
+ } else if (Math.abs(delta[1]) > config.epsilon && config.allowTilt) {
+ currentState = state.DraggingTilt;
+ } else {
+ return;
+ }
+ }
+
+ var previousSpin = this.getAngles()[0];
+ currentPosition = position.slice(0);
+
+ // This is purely for trackpads:
+ var spinDelta = this.getAngles()[0] - previousSpin;
+ inertiaSpeed = initialInertia * spinDelta;
+ };
+
+ this.getAngles = function() {
+ var delta = V2.subtract(currentPosition, startPosition);
+ var spin = currentSpin;
+ var tilt = currentTilt;
+ if (currentState == state.DraggingSpin) {
+ var radians = config.radiansPerPixel[0] * delta[0];
+ spin += radians;
+ } else if (currentState == state.DraggingTilt) {
+ var radians = config.radiansPerPixel[1] * delta[1];
+ tilt += radians;
+ } else if (!config.lockAxes && currentState == state.DraggingInit) {
+ spin += config.radiansPerPixel[0] * delta[0];
+ tilt += config.radiansPerPixel[1] * delta[1];
+ }
+ return [spin, tilt];
+ };
+
+ this.getRotation = function() {
+ var r = this.getAngles();
+ var spin = M3.rotationY(r[0]);
+ var tilt = M3.rotationX(r[1]);
+ return M3.multiply(tilt, spin);
+ };
+
+ // When releasing the mouse, capture the current rotation and change
+ // the state machine back to 'Resting' or 'SpinningInertia'.
+ this.endDrag = function(position) {
+ var previousSpin = this.getAngles()[0];
+ currentPosition = position.slice(0);
+ var r = this.getAngles();
+ currentSpin = r[0];
+ currentTilt = r[1];
+
+ var spinDelta = currentSpin - previousSpin;
+
+ if (config.spinFriction == 1) {
+ currentState = state.Resting;
+ } else {
+ currentState = state.SpinningInertia;
+ inertiaSpeed = initialInertia * spinDelta;
+ }
+
+ };
+
+};
+
+GIZA.Turntable.INFINITE = 1000; // not sure if I'll use this
\ No newline at end of file
diff --git a/index.js b/index.js
new file mode 100644
index 0000000..d40f691
--- /dev/null
+++ b/index.js
@@ -0,0 +1,19 @@
+import { vec3, quat } from 'gl-matrix';
+
+const TWOPI = 2 * Math.PI;
+
+export default class Trackball {
+ constructor() {
+ this.defaults = Object.freeze({
+ startSpin: 0.001, // radians per second
+ allowTilt: true,
+ allowSpin: true,
+ spinFriction: 0.125, // 0 means no friction (infinite spin) while 1 means no inertia
+ epsilon: 3, // distance (in pixels) to wait before deciding if a drag is a Tilt or a Spin
+ radiansPerPixel: V2.make(0.01, 0.01),
+ trackpad: true, // if true, compensate for the delay on trackpads that occur between touchup and mouseup
+ lockAxes: false, // if true, don't allow simultaneous spin + tilt
+ homeTilt: 0.25,
+ });
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..5646414
--- /dev/null
+++ b/package.json
@@ -0,0 +1,49 @@
+{
+ "name": "gltumble",
+ "version": "0.0.0",
+ "description": "Spin an object by dragging with a mouse, trackpad, or touch screen",
+ "main": "gltumble.js",
+ "module": "index.js",
+ "jsdelivr": "gltumble.min.js",
+ "unpkg": "gltumble.min.js",
+ "dependencies": {
+ "gl-matrix": "^2.8.1"
+ },
+ "devDependencies": {
+ "c8": "^3.2.0",
+ "eslint": "^5.6.0",
+ "eslint-config-mourner": "^3.0.0",
+ "esm": "^3.0.84",
+ "rollup": "^0.66.2",
+ "rollup-plugin-buble": "^0.19.2",
+ "rollup-plugin-node-resolve": "^3.4.0",
+ "rollup-plugin-terser": "^3.0.0",
+ "tape": "^4.9.1"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/prideout/gltumble.git"
+ },
+ "scripts": {
+ "disable__lint": "eslint index.js test.js rollup.config.js",
+ "disable__pretest": "npm run lint",
+ "test": "node -r esm test.js",
+ "disable__cov": "c8 node -r esm test.js && c8 report -r html",
+ "build": "rollup -c",
+ "clean": "rm gltumble.*",
+ "watch": "rollup -cw",
+ "disable__prepublishOnly": "npm test && npm run build"
+ },
+ "files": [
+ "gltumble.js",
+ "gltumble.min.js"
+ ],
+ "eslintConfig": {
+ "extends": "mourner",
+ "rules": {
+ "no-sequences": 0
+ }
+ },
+ "author": "Philip Rideout",
+ "license": "MIT"
+}
diff --git a/rollup.config.js b/rollup.config.js
new file mode 100644
index 0000000..9984acb
--- /dev/null
+++ b/rollup.config.js
@@ -0,0 +1,20 @@
+import {terser} from 'rollup-plugin-terser';
+import buble from 'rollup-plugin-buble';
+import resolve from 'rollup-plugin-node-resolve';
+
+const config = (file, plugins) => ({
+ input: 'index.js',
+ output: {
+ name: 'gltumble',
+ format: 'umd',
+ file
+ },
+ plugins
+});
+
+const bubleConfig = {transforms: {dangerousForOf: true}};
+
+export default [
+ config('gltumble.js', [resolve(), buble(bubleConfig)]),
+ config('gltumble.min.js', [resolve(), terser(), buble(bubleConfig)])
+];
diff --git a/test.js b/test.js
new file mode 100644
index 0000000..51916ea
--- /dev/null
+++ b/test.js
@@ -0,0 +1,8 @@
+import test from 'tape';
+import Trackball from './index.js';
+
+test('smoke', (t) => {
+ const trackball = new Trackball(data);
+ t.notSame(trackball, null);
+ t.end();
+});