diff --git a/.eslintignore b/.eslintignore index 3598db9d..06725063 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,2 @@ test/**/*.js +build/* \ No newline at end of file diff --git a/.eslintrc b/.eslintrc index 82761608..fcbffa40 100644 --- a/.eslintrc +++ b/.eslintrc @@ -18,8 +18,8 @@ "new-cap": [2, {"newIsCap":true, "capIsNew":false}], "prefer-const": [1], "no-alert": [0], - "key-spacing": [2, {"beforeColon": false, "afterColon": true}], "camelcase": [1], + "semi": [1, "always"], "no-multiple-empty-lines": [2, {"max": 1}], "dot-notation": [1], "no-unexpected-multiline": [2] diff --git a/.gitignore b/.gitignore index eb0df3ea..23b7db3c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules .DS_Store # IntelliJ modules *.iml +.idea # On master - we don't want to check in every bundle we make! npm-debug.log coverage diff --git a/.travis.yml b/.travis.yml index 19ff1e23..bd07ce53 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,7 +1,7 @@ sudo: false language: node_js node_js: -- '4.1' +- '5.0' install: - npm install - gem install s3_website diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 00000000..c6f6846c --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,13 @@ +{ + // See https://go.microsoft.com/fwlink/?LinkId=759670 + // for the documentation about the jsconfig.json format + "compilerOptions": { + "target": "es5", + "module": "commonjs", + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "build" + ] +} diff --git a/package.json b/package.json index bc2780d0..d6b7e79e 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "build:webpack": "webpack --config ./webpack/webpack.prod.config.js", "build:appcache": "sed 's///g' ./build/index.html > tmp && mv tmp ./build/index.html", "build": "NODE_ENV=production npm run build:prep && npm run build:webpack && npm run build:appcache", - "watch:webpack-dev-server": "webpack-dev-server --config ./webpack/webpack.dev.config.js --colours --content-base ./build", + "watch:webpack-dev-server": "webpack-dev-server --config ./webpack/webpack.dev.config.js --colours --content-base ./build --port 4000 --host 0.0.0.0", "watch": "npm run build:prep && npm run watch:webpack-dev-server", "test": "mocha --compilers js:babel/register -R spec --recursive ./test", "coverage": "istanbul cover _mocha -- --compilers js:babel/register -R dot --recursive ./test", diff --git a/src/js/app/model/app.js b/src/js/app/model/app.js index ee0a3e17..a7642a5b 100644 --- a/src/js/app/model/app.js +++ b/src/js/app/model/app.js @@ -132,6 +132,10 @@ export default Backbone.Model.extend({ return this.get('landmarks'); }, + landmarkSize: function () { + return this.get('landmarkSize'); + }, + initialize: function () { _.bindAll(this, 'assetChanged', 'mesh', 'assetSource', 'landmarks'); @@ -143,6 +147,11 @@ export default Backbone.Model.extend({ this._initCollections(); }, + budgeLandmarks: function(vector) { + // call our onBudgeLandmarks callback + this.onBudgeLandmarks(vector) + }, + _initTemplates: function (override=false) { // firstly, we need to find out what template we will use. // construct a template labels model to go grab the available labels. diff --git a/src/js/app/model/asset.js b/src/js/app/model/asset.js index 37ab85c5..b9242507 100644 --- a/src/js/app/model/asset.js +++ b/src/js/app/model/asset.js @@ -64,6 +64,10 @@ export const Image = Backbone.Model.extend({ return { textureOn: true }; }, + server: function () { + return this.get('server'); + }, + hasTexture: function() { return this.hasOwnProperty('texture'); }, @@ -179,7 +183,7 @@ export const Image = Backbone.Model.extend({ loadThumbnail: function () { if (!this.hasOwnProperty('_thumbnailPromise')) { - this._thumbnailPromise = this.get('server').fetchThumbnail(this.id).then((material) => { + this._thumbnailPromise = this.server().fetchThumbnail(this.id).then((material) => { delete this._thumbnailPromise; console.log('Asset: loaded thumbnail for ' + this.id); this.thumbnail = material; @@ -196,7 +200,7 @@ export const Image = Backbone.Model.extend({ loadTexture: function () { if (!this.hasOwnProperty('_texturePromise')) { - this._texturePromise = this.get('server').fetchTexture(this.id).then((material) => { + this._texturePromise = this.server().fetchTexture(this.id).then((material) => { delete this._texturePromise; console.log('Asset: loaded texture for ' + this.id); this.texture = material; @@ -258,7 +262,7 @@ export const Image = Backbone.Model.extend({ export const Mesh = Image.extend({ geometryUrl: function () { - return this.get('server').map('meshes/' + this.id); + return this.server().map('meshes/' + this.id); }, loadGeometry: function () { @@ -266,7 +270,7 @@ export const Mesh = Image.extend({ // already loading this geometry return this._geometryPromise; } - const arrayPromise = this.get('server').fetchGeometry(this.id); + const arrayPromise = this.server().fetchGeometry(this.id); if (arrayPromise.isGeometry) { // Backend says it parses the geometry this._geometryPromise = arrayPromise.then((geometry) => { diff --git a/src/js/app/model/assetsource.js b/src/js/app/model/assetsource.js index 41926222..64ee4965 100644 --- a/src/js/app/model/assetsource.js +++ b/src/js/app/model/assetsource.js @@ -19,9 +19,13 @@ const AssetSource = Backbone.Model.extend({ return { assets: new Backbone.Collection(), assetIsLoading: false }; }, + server: function () { + return this.get('server'); + }, + fetch: function () { return ( - this.get('server').fetchCollection(this.id).then((response) => { + this.server().fetchCollection(this.id).then((response) => { this.set('assets', this.parse(response).assets); }) ); @@ -95,7 +99,7 @@ export const MeshSource = AssetSource.extend({ const meshes = response.map((assetId) => { return new Asset.Mesh({ id: assetId, - server: this.get('server') + server: this.server() }); }); @@ -103,7 +107,7 @@ export const MeshSource = AssetSource.extend({ }, setAsset: function (newMesh) { - var oldAsset = this.get('asset'); + var oldAsset = this.asset(); // stop listening to the old asset if (oldAsset) { this.stopListening(oldAsset); @@ -165,7 +169,7 @@ export const ImageSource = AssetSource.extend({ const images = response.map((assetId) => { return new Asset.Image({ id: assetId, - server: this.get('server') + server: this.server() }); }); @@ -173,7 +177,7 @@ export const ImageSource = AssetSource.extend({ }, setAsset: function (newAsset) { - const oldAsset = this.get('asset'); + const oldAsset = this.asset(); // stop listening to the old asset if (oldAsset) { this.stopListening(oldAsset); diff --git a/src/js/app/model/landmark.js b/src/js/app/model/landmark.js index 1e9e83c0..b63232e7 100644 --- a/src/js/app/model/landmark.js +++ b/src/js/app/model/landmark.js @@ -29,6 +29,10 @@ export default Backbone.Model.extend({ return this.get('point'); }, + index: function () { + return this.get('index'); + }, + setPoint: function (p) { this.set('point', p); }, diff --git a/src/js/app/model/landmark_group.js b/src/js/app/model/landmark_group.js index 6cb2eecf..377b19aa 100644 --- a/src/js/app/model/landmark_group.js +++ b/src/js/app/model/landmark_group.js @@ -191,7 +191,7 @@ LandmarkGroup.prototype.resetNextAvailable = function (originLm) { LandmarkGroup.prototype.deleteSelected = atomicOperation(function () { const ops = []; this.selected().forEach(function (lm) { - ops.push([lm.get('index'), lm.point().clone(), undefined]); + ops.push([lm.index(), lm.point().clone(), undefined]); lm.clear(); }); // reactivate the group to reset next available. @@ -215,9 +215,8 @@ LandmarkGroup.prototype.setLmAt = atomicOperation(function (lm, v) { if (!v) { return; } - this.tracker.record([ - [ lm.get('index'), + [lm.index(), lm.point() ? lm.point().clone() : undefined, v.clone() ] ]); diff --git a/src/js/app/view/bbviewport.js b/src/js/app/view/bbviewport.js new file mode 100644 index 00000000..60fd3565 --- /dev/null +++ b/src/js/app/view/bbviewport.js @@ -0,0 +1,91 @@ +'use strict'; + +import { Viewport } from './viewport'; + +const landmarkForBBLandmark = bbLm => ({ + point: bbLm.point(), + isSelected: bbLm.isSelected(), + index: bbLm.index() +}); + +// A wrapper around the standalone viewport that hooks it into the legacy +// Backbone Landmarker.io code. + +export class BackboneViewport { + + constructor(app) { + this.model = app; + + this.model.onBudgeLandmarks = vector => this.viewport.budgeLandmarks(vector); + + const on = { + selectLandmarks: is => is.forEach(i => this.model.landmarks().landmarks[i].select()), + deselectLandmarks: is => is.forEach(i => this.model.landmarks().landmarks[i].deselect()), + deselectAllLandmarks: () => { + const lms = this.model.landmarks(); + if (lms) { + lms.deselectAll() + } + }, + selectLandmarkAndDeselectRest: i => this.model.landmarks().landmarks[i].selectAndDeselectRest(), + setLandmarkPoint: (i, point) => this.model.landmarks().setLmAt(this.model.landmarks().landmarks[i], point), + setLandmarkPointWithHistory: (i, point) => this.model.landmarks().landmarks[i].setPoint(point), + addLandmarkHistory: points => this.model.landmarks().tracker.record(points), + insertNewLandmark: point => this.model.landmarks().insertNew(point) + }; + this.viewport = new Viewport(app.meshMode(), on); + + this.model.on('newMeshAvailable', this.setMesh); + this.model.on("change:landmarks", this.setLandmarks); + this.model.on("change:landmarkSize", this.setLandmarkSize); + this.model.on("change:connectivityOn", this.updateConnectivityDisplay); + this.model.on("change:editingOn", this.updateEditingDisplay); + + // make sure we didn't miss any state changes on load + this.setMesh(); + this.setLandmarkSize(); + this.updateConnectivityDisplay(); + this.updateEditingDisplay(); + } + + setMesh = () => { + const meshPayload = this.model.mesh(); + if (meshPayload === null) { + return; + } + this.viewport.setMesh(meshPayload.mesh, meshPayload.up, meshPayload.front); + }; + + setLandmarks = () => { + const landmarks = this.model.landmarks(); + if (landmarks !== null) { + this.viewport.setLandmarksAndConnectivity(landmarks.landmarks.map(landmarkForBBLandmark), + landmarks.connectivity); + + // TODO will this be collected properly? + landmarks.landmarks.forEach(lm => lm.on('change', () => this.updateLandmark(lm.index()))); + } + + }; + + setLandmarkSize = () => { + this.viewport.setLandmarkSize(this.model.landmarkSize()); + }; + + updateEditingDisplay = () => { + this.viewport.updateEditingDisplay(this.model.isEditingOn()); + }; + + updateConnectivityDisplay = () => { + this.viewport.updateConnectivityDisplay(this.model.isConnectivityOn()); + }; + + updateLandmark = i => { + console.log(`updating landmark ${i}`); + this.viewport.updateLandmarks([ + landmarkForBBLandmark(this.model.landmarks().landmarks[i]) + ] + ) + }; + +} diff --git a/src/js/app/view/keyboard.js b/src/js/app/view/keyboard.js index a245b7db..4bdfcfde 100644 --- a/src/js/app/view/keyboard.js +++ b/src/js/app/view/keyboard.js @@ -9,12 +9,10 @@ const SHORTCUTS = { // Shortcuts activated without SHIFT, but CAPS LOCK ok (letters) "d": [function (lms) { // d = [d]elete selected lms.deleteSelected(); - $('#viewportContainer').trigger("groupDeselected"); }, false, true], "q": [function (lms) { // q = deselect all lms.deselectAll(); - $('#viewportContainer').trigger("groupDeselected"); }, false, true], "r": [function (lms, app, viewport) { // r = [r]eset camera @@ -30,11 +28,10 @@ const SHORTCUTS = { "a": [function (lms, app) { // a = select [a]ll app.landmarks().selectAll(); - $('#viewportContainer').trigger("groupSelected"); }, false, true], - "g": [function () { // g = complete [g]roup selection - $('#viewportContainer').trigger("completeGroupSelection"); + "g": [function (lms) { // g = complete [g]roup selection + lms.completeGroups() }, false, true], "c": [function (lms, app, viewport) { // c = toggle [c]amera mode @@ -138,7 +135,6 @@ export default function KeyboardShortcutsHandler (app, viewport) { lms = app.landmarks(); if (lms) { app.landmarks().deselectAll(); - $('#viewportContainer').trigger("groupDeselected"); evt.stopPropagation(); return null; } @@ -148,6 +144,15 @@ export default function KeyboardShortcutsHandler (app, viewport) { if (lms) { lms.save(); } + } else if (evt.which >= 37 && evt.which <= 40) { // arrow keys + // Up and down are inverted due to the way THREE handles coordinates + const vector = { + 37: [-1, 0], // Left + 38: [0, -1], // Up + 39: [1, 0], // Right + 40: [0, 1] // Down + }[evt.which]; + app.budgeLandmarks(vector) } }; } diff --git a/src/js/app/view/sidebar.js b/src/js/app/view/sidebar.js index a046d4de..f60c757f 100644 --- a/src/js/app/view/sidebar.js +++ b/src/js/app/view/sidebar.js @@ -63,7 +63,6 @@ export const LandmarkView = Backbone.View.extend({ } else if (event.ctrlKey || event.metaKey) { if (!this.model.isSelected()) { this.model.select(); - $('#viewportContainer').trigger("groupSelected"); } } else if (this.model.isEmpty()) { // user is clicking on an empty landmark - mark it as the next for @@ -77,12 +76,10 @@ export const LandmarkView = Backbone.View.extend({ selectGroup: function () { this.model.group().deselectAll(); this.model.group().labels[this.labelIndex].selectAll(); - $('#viewportContainer').trigger("groupSelected"); }, selectAll: function () { this.model.group().selectAll(); - $('#viewportContainer').trigger("groupSelected"); } }); diff --git a/src/js/app/view/viewport/camera.js b/src/js/app/view/viewport/camera.js index 21cbe03b..8fc9b9db 100644 --- a/src/js/app/view/viewport/camera.js +++ b/src/js/app/view/viewport/camera.js @@ -1,3 +1,28 @@ +'use strict'; + +import THREE from 'three'; +import $ from 'jquery'; + +const MOUSE_WHEEL_SENSITIVITY = 0.5; +const ROTATION_SENSITIVITY = 3.5; +const DAMPING_FACTOR = 0.2; +const PIP_ZOOM_FACTOR = 12.0; +// const EPS = 0.000001; + +const STATE = { + NONE: -1, + ROTATE: 0, + ZOOM: 1, + PAN: 2 +}; + +// see https://developer.mozilla.org/en-US/docs/Web/API/WheelEvent.deltaMode +const UNITS_FOR_MOUSE_WHEEL_DELTA_MODE = { + 0: 1.0, // The delta values are specified in pixels. + 1: 34.0, // The delta values are specified in lines. + 2: 1.0 // The delta values are specified in pages. +}; + /** * Controller for handling basic camera events on a Landmarker. * @@ -21,37 +46,9 @@ * for instance) can disable the Controller temporarily with the enabled * property. */ -'use strict'; - -import _ from 'underscore'; -import THREE from 'three'; -import $ from 'jquery'; -import Backbone from 'backbone'; - -const MOUSE_WHEEL_SENSITIVITY = 0.5; -const ROTATION_SENSITIVITY = 3.5; -const DAMPING_FACTOR = 0.2; -const PIP_ZOOM_FACTOR = 12.0; -// const EPS = 0.000001; - -// see https://developer.mozilla.org/en-US/docs/Web/API/WheelEvent.deltaMode -const UNITS_FOR_MOUSE_WHEEL_DELTA_MODE = { - 0: 1.0, // The delta values are specified in pixels. - 1: 34.0, // The delta values are specified in lines. - 2: 1.0 // The delta values are specified in pages. -}; - -export default function CameraController (pCam, oCam, oCamZoom, domElement) { +export function CameraController (pCam, oCam, oCamZoom, domElement) { const controller = {}; - _.extend(controller, Backbone.Events); - - const STATE = { - NONE: -1, - ROTATE: 0, - ZOOM: 1, - PAN: 2 - }; let state = STATE.NONE; // the current state of the Camera let canRotate = true; @@ -141,8 +138,9 @@ export default function CameraController (pCam, oCam, oCamZoom, domElement) { pCam.updateProjectionMatrix(); } - const tvec = new THREE.Vector3(); // a temporary vector for efficient maths - const tinput = new THREE.Vector3(); // temp vec used for + // temporary vectors for efficient maths + const tvec = new THREE.Vector3(); + const tinput = new THREE.Vector3(); const normalMatrix = new THREE.Matrix3(); @@ -178,7 +176,7 @@ export default function CameraController (pCam, oCam, oCamZoom, domElement) { oCam.top += deltaV; oCam.bottom += deltaV; oCam.updateProjectionMatrix(); - controller.trigger('change'); + _change(); } function zoom (distance) { @@ -221,7 +219,7 @@ export default function CameraController (pCam, oCam, oCamZoom, domElement) { pageX: mouseHoverPosition.x, pageY: mouseHoverPosition.y }); - controller.trigger('change'); + _change(); } function distanceToTarget () { @@ -306,7 +304,7 @@ export default function CameraController (pCam, oCam, oCamZoom, domElement) { rotateCamera(delta, pCam, singleDir); rotateCamera(delta, oCam, singleDir); rotateCamera(delta, oCamZoom, singleDir); - controller.trigger('change'); + _change(); } // mouse @@ -422,7 +420,19 @@ export default function CameraController (pCam, oCam, oCamZoom, domElement) { oCamZoom.updateProjectionMatrix(); // emit a special change event. If the viewport is // interested (i.e. we are in PIP mode) it can update - controller.trigger('changePip'); + _changePip(); + } + + function _changePip() { + if (controller.onChangePip !== null) { + controller.onChangePip(); + } + } + + function _change() { + if (controller.onChange !== null) { + controller.onChange(); + } } function onMouseUp (event) { @@ -507,6 +517,8 @@ export default function CameraController (pCam, oCam, oCamZoom, domElement) { controller.focus = focus; controller.position = position; controller.reset = reset; + controller.onChange = null; + controller.onChangePip = null; return controller; } diff --git a/src/js/app/view/viewport/elements.js b/src/js/app/view/viewport/elements.js deleted file mode 100644 index f37f477c..00000000 --- a/src/js/app/view/viewport/elements.js +++ /dev/null @@ -1,161 +0,0 @@ -'use strict'; - -import _ from 'underscore'; -import THREE from 'three'; -import Backbone from 'backbone'; - -// the default scale for 1.0 -const LM_SCALE = 0.01; - -const LM_SPHERE_PARTS = 10; -const LM_SPHERE_SELECTED_COLOR = 0xff75ff; -const LM_SPHERE_UNSELECTED_COLOR = 0xffff00; -const LM_CONNECTION_LINE_COLOR = LM_SPHERE_UNSELECTED_COLOR; - -// create a single geometry + material that will be shared by all landmarks -const lmGeometry = new THREE.SphereGeometry( - LM_SCALE, - LM_SPHERE_PARTS, - LM_SPHERE_PARTS); - -const lmMaterialForSelected = { - true: new THREE.MeshBasicMaterial({color: LM_SPHERE_SELECTED_COLOR}), - false: new THREE.MeshBasicMaterial({color: LM_SPHERE_UNSELECTED_COLOR}) -}; - -const lineMaterial = new THREE.LineBasicMaterial({ - color: LM_CONNECTION_LINE_COLOR, - linewidth: 1 -}); - -export const LandmarkTHREEView = Backbone.View.extend({ - - initialize: function (options) { - _.bindAll(this, 'render', 'changeLandmarkSize'); - this.listenTo(this.model, "change", this.render); - this.viewport = options.viewport; - this.app = this.viewport.model; - this.listenTo(this.app, "change:landmarkSize", this.changeLandmarkSize); - this.symbol = null; // a THREE object that represents this landmark. - // null if the landmark isEmpty - this.render(); - }, - - render: function () { - if (this.symbol) { - // this landmark already has an allocated representation.. - if (this.model.isEmpty()) { - // but it's been deleted. - this.dispose(); - } else { - // the lm may need updating. See what needs to be done - this.updateSymbol(); - } - } else { - // there is no symbol yet - if (!this.model.isEmpty()) { - // and there should be! Make it and update it - this.symbol = this.createSphere(this.model.get('point'), true); - this.updateSymbol(); - // trigger changeLandmarkSize to make sure sizing is correct - this.changeLandmarkSize(); - // and add it to the scene - this.viewport.sLms.add(this.symbol); - } - } - // tell our viewport to update - this.viewport.update(); - }, - - createSphere: function (v, radius, selected) { - //console.log('creating sphere of radius ' + radius); - var landmark = new THREE.Mesh(lmGeometry, lmMaterialForSelected[selected]); - landmark.name = 'Landmark ' + landmark.id; - landmark.position.copy(v); - return landmark; - }, - - updateSymbol: function () { - this.symbol.position.copy(this.model.point()); - var selected = this.model.isSelected(); - this.symbol.material = lmMaterialForSelected[selected]; - }, - - dispose: function () { - if (this.symbol) { - this.viewport.sLms.remove(this.symbol); - this.symbol = null; - } - }, - - changeLandmarkSize: function () { - if (this.symbol) { - // have a symbol, and need to change it's size. - var r = this.app.get('landmarkSize') * this.viewport.meshScale; - this.symbol.scale.set(r, r, r); - // tell our viewport to update - this.viewport.update(); - } - } -}); - -export const LandmarkConnectionTHREEView = Backbone.View.extend({ - - initialize: function (options) { - // Listen to both models for changes - this.listenTo(this.model[0], "change", this.render); - this.listenTo(this.model[1], "change", this.render); - this.viewport = options.viewport; - this.symbol = null; // a THREE object that represents this connection. - // null if the landmark isEmpty - this.render(); - }, - - render: function () { - if (this.symbol !== null) { - // this landmark already has an allocated representation.. - if (this.model[0].isEmpty() || this.model[1].isEmpty()) { - // but it's been deleted. - this.dispose(); - - } else { - // the connection may need updating. See what needs to be done - this.updateSymbol(); - } - } else { - // there is no symbol yet - if (!this.model[0].isEmpty() && !this.model[1].isEmpty()) { - // and there should be! Make it and update it - this.symbol = this.createLine(this.model[0].get('point'), - this.model[1].get('point')); - this.updateSymbol(); - // and add it to the scene - this.viewport.sLmsConnectivity.add(this.symbol); - } - } - // tell our viewport to update - this.viewport.update(); - }, - - createLine: function (start, end) { - var geometry = new THREE.Geometry(); - geometry.dynamic = true; - geometry.vertices.push(start.clone()); - geometry.vertices.push(end.clone()); - return new THREE.Line(geometry, lineMaterial); - }, - - dispose: function () { - if (this.symbol) { - this.viewport.sLmsConnectivity.remove(this.symbol); - this.symbol.geometry.dispose(); - this.symbol = null; - } - }, - - updateSymbol: function () { - this.symbol.geometry.vertices[0].copy(this.model[0].point()); - this.symbol.geometry.vertices[1].copy(this.model[1].point()); - this.symbol.geometry.verticesNeedUpdate = true; - } -}); diff --git a/src/js/app/view/viewport/elements/connection.js b/src/js/app/view/viewport/elements/connection.js new file mode 100644 index 00000000..1c913d5b --- /dev/null +++ b/src/js/app/view/viewport/elements/connection.js @@ -0,0 +1,67 @@ +'use strict'; + +import THREE from 'three'; + +const LINE_COLOR = 0xffff00; + +const LINE_MATERIAL = new THREE.LineBasicMaterial({ + color: LINE_COLOR, + linewidth: 1 +}); + +function createLine(start, end) { + const geometry = new THREE.Geometry(); + geometry.dynamic = true; + geometry.vertices.push(start.clone()); + geometry.vertices.push(end.clone()); + return new THREE.Line(geometry, LINE_MATERIAL) +} + +export class LandmarkConnectionTHREEView { + + constructor(lmA, lmB, options) { + this.onCreate = options.onCreate; + this.onDispose = options.onDispose; + this.symbol = null; // a THREE object that represents this connection. + // null if the landmark isEmpty + this.render(lmA, lmB); + } + + render = (lmA, lmB) => { + const shouldBeVisible = lmA.point !== null && lmB.point !== null; + if (this.symbol !== null) { + // this landmark already has an allocated representation.. + if (!shouldBeVisible) { + // but it's been deleted. + this.dispose(); + } else { + // the connection may need updating. See what needs to be done + this.updateSymbol(lmA, lmB); + } + } else { + // there is no symbol yet + if (shouldBeVisible) { + // and there should be! Make it and update it + this.symbol = createLine(lmA.point, lmB.point); + this.updateSymbol(lmA, lmB); + // and add it to the scene + this.onCreate(this.symbol); + } + } + }; + + updateSymbol = (lmA, lmB) => { + this.symbol.geometry.vertices[0].copy(lmA.point); + this.symbol.geometry.vertices[1].copy(lmB.point); + this.symbol.geometry.verticesNeedUpdate = true; + }; + + dispose = () => { + if (this.symbol) { + this.onDispose(this.symbol); + this.symbol.geometry.dispose(); + this.symbol = null; + } + }; + +} diff --git a/src/js/app/view/viewport/elements/landmark.js b/src/js/app/view/viewport/elements/landmark.js new file mode 100644 index 00000000..e6bdd0c4 --- /dev/null +++ b/src/js/app/view/viewport/elements/landmark.js @@ -0,0 +1,77 @@ +'use strict'; + +import THREE from 'three'; + +const LM_SCALE = 0.01; // the default scale for 1.0 +const LM_SPHERE_PARTS = 10; +const LM_SPHERE_SELECTED_COLOR = 0xff75ff; +const LM_SPHERE_UNSELECTED_COLOR = 0xffff00; + +// create a single geometry + material that will be shared by all landmarks +const LM_GEOMETRY = new THREE.SphereGeometry( + LM_SCALE, + LM_SPHERE_PARTS, + LM_SPHERE_PARTS +); + +const LM_MATERIAL_FOR_SELECTED = { + true: new THREE.MeshBasicMaterial({color: LM_SPHERE_SELECTED_COLOR}), + false: new THREE.MeshBasicMaterial({color: LM_SPHERE_UNSELECTED_COLOR}) +}; + +function createSphere(index) { + const landmark = new THREE.Mesh(LM_GEOMETRY, LM_MATERIAL_FOR_SELECTED[false]); + landmark.name = 'Landmark ' + index; + landmark.userData.index = index; + return landmark +} + +export class LandmarkTHREEView { + + constructor (lm, options) { + this.onCreate = options.onCreate; + this.onDispose = options.onDispose; + this.onUpdate = options.onUpdate; + + // a THREE object that represents this landmark. + // null if the landmark isEmpty + this.symbol = null; + this.index = lm.index + + this.render(lm); + } + + render = (lm) => { + if (this.symbol) { + // this landmark already has an allocated representation.. + if (lm.point === null) { + // but it's been deleted. + this.dispose(); + } else { + // the lm may need updating. See what needs to be done + this.updateSymbol(lm); + } + } else { + // there is no symbol yet + if (lm.point !== null) { + // and there should be! Make it and update it + this.symbol = createSphere(lm.index); + this.updateSymbol(lm); + // and add it to the scene + this.onCreate(this.symbol); + } + } + }; + + updateSymbol = (lm) => { + this.symbol.position.copy(lm.point); + this.symbol.material = LM_MATERIAL_FOR_SELECTED[lm.isSelected]; + }; + + dispose = () => { + if (this.symbol) { + this.onDispose(this.symbol); + this.symbol = null; + } + } +} diff --git a/src/js/app/view/viewport/handler.js b/src/js/app/view/viewport/handler.js index d94c4396..e1ff0661 100644 --- a/src/js/app/view/viewport/handler.js +++ b/src/js/app/view/viewport/handler.js @@ -1,260 +1,233 @@ 'use strict'; -import _ from 'underscore'; import THREE from 'three'; import $ from 'jquery'; import atomic from '../../model/atomic'; +// Helpers +// ------------------------------------------------------------------------ + +const findClosestLandmarks = (lms, point, n = 4) => + lms + .map(lm => ({ landmark: lm, distance: point.distanceTo(lm.point) })) + .sort((a, b) => a.distance - b.distance) + .slice(0, n) + .map(lmd => lmd.landmark); + /** - * Create a closure for handling mouse events in viewport. * Holds state usable by all event handlers and should be bound to the * Viewport view instance. */ -export default function Handler () { +export default class Handler { - // Helpers - // ------------------------------------------------------------------------ + constructor(viewport) { - /** - * Find the 4 landmarks closest to a location (THREE vector) - * from a LandmarkGroup - * - * @param {LandmarkGroup} lmGroup - * @param {THREE.Vector} loc - * - * @return {Landmark[]} - */ - var findClosestLandmarks = (lmGroup, loc, locked=false) => { - var dist, i, j, lm, lmLoc, minDist, - dists = new Array(4), lms = new Array(4); - - for (i = lmGroup.landmarks.length - 1; i >= 0; i--) { - lm = lmGroup.landmarks[i]; - - if (lm.isEmpty()) { - continue; - } + this.viewport = viewport; - lmLoc = lm.point(); + // Setup handler state variables + // ------------------------------------------------------------------------ + this.currentTargetLm = undefined; + this.downEvent = null; - if (lmLoc === null || locked && lm === currentTargetLm) { - continue; - } - - dist = loc.distanceTo(lmLoc); - - // Compare to stored lm in order, 0 being the closest - for (j = 0; j < 3; j++) { - minDist = dists[j]; - if (!minDist) { - [dists[j], lms[j]] = [dist, lm]; - break; - } else if (dist <= minDist) { // leq to ensure we always have 4 - dists.splice(j, 0, dist); - lms.splice(j, 0, lm); - break; - } - } - } + this.lmPressed = false; + this.isPressed = false; + this.groupSelected = false; - return lms; - }; + // x, y position of mouse on click states + this.onMouseDownPosition = new THREE.Vector2(); + this.onMouseUpPosition = new THREE.Vector2(); - // Setup handler state variables - // ------------------------------------------------------------------------ - var downEvent, - lmPressed, lmPressedWasSelected, - isPressed, groupSelected, - currentTargetLm; + // current screen position when in drag state + this.positionLmDrag = new THREE.Vector2(); - // x, y position of mouse on click states - var onMouseDownPosition = new THREE.Vector2(), - onMouseUpPosition = new THREE.Vector2(); + // vector difference in one time step + this.deltaLmDrag = new THREE.Vector2(); - // current screen position when in drag state - var positionLmDrag = new THREE.Vector2(); - // vector difference in one time step - var deltaLmDrag = new THREE.Vector2(); - var dragStartPositions, dragged = false; + this.dragStartPositions = []; + this.dragged = false; - var intersectsWithLms, intersectsWithMesh; + this.intersectsWithLms = []; + this.intersectsWithMesh = []; - var landmarkOnDrag, shiftOnDrag, - landmarkOnMouseUp, nothingOnMouseUp, shiftOnMouseUp, - setGroupSelected; + } - // Press handling + // High level handlers + // these functions respond to changes in the mesh and landamrks state. + // lower level handlers below reponding to raw input (e.g. onMouseDown) will + // resolve what item is being interacted with and delegate to these methods + // as appropriate. // ------------------------------------------------------------------------ - var meshPressed = () => { + meshPressed = () => { console.log('mesh pressed!'); - if (groupSelected) { - nothingPressed(); - } else if (downEvent.button === 0 && downEvent.shiftKey) { - shiftPressed(); // LMB + SHIFT + if (this.viewport._groupModeActive) { + this.nothingPressed(); + } else if (this.downEvent.button === 0 && this.downEvent.shiftKey) { + this.shiftPressed(); // LMB + SHIFT } else { - $(document).one('mouseup.viewportMesh', meshOnMouseUp); + $(document).one('mouseup.viewportMesh', this.meshOnMouseUp); } }; - var landmarkPressed = () => { - var ctrl = downEvent.ctrlKey || downEvent.metaKey; + // called when the landmarks are changed on the viewport. + resetLandmarks = () => { + this.currentTargetLm = undefined + }; + + landmarkPressed = () => { + var ctrl = this.downEvent.ctrlKey || this.downEvent.metaKey; console.log('Viewport: landmark pressed'); // before anything else, disable the camera - this.cameraController.disable(); + this.viewport.cameraController.disable(); // the clicked on landmark - var landmarkSymbol = intersectsWithLms[0].object; + var landmarkSymbol = this.intersectsWithLms[0].object; // hunt through the landmarkViews for the right symbol - for (var i = 0; i < this.landmarkViews.length; i++) { - if (this.landmarkViews[i].symbol === landmarkSymbol) { - lmPressed = this.landmarkViews[i].model; - } - } + console.log(landmarkSymbol); + + this.viewport._landmarkViews + .filter(lmv => lmv.symbol === landmarkSymbol) + .forEach(lmv => this.lmPressed = this.viewport._landmarks[lmv.index]); console.log('Viewport: finding the selected points'); - lmPressedWasSelected = lmPressed.isSelected(); - if (!lmPressedWasSelected && !ctrl) { + if (!this.lmPressed.isSelected && !ctrl) { // this lm wasn't pressed before and we aren't holding // mutliselection down - deselect rest and select this console.log("normal click on a unselected lm - deselecting rest and selecting me"); - lmPressed.selectAndDeselectRest(); - } else if (ctrl && !lmPressedWasSelected) { - lmPressed.select(); + this.viewport.on.selectLandmarkAndDeselectRest(this.lmPressed.index); + } else if (ctrl && !this.lmPressed.isSelected) { + this.viewport.on.selectLandmarks([this.lmPressed.index]); } // record the position of where the drag started. - positionLmDrag.copy(this.localToScreen(lmPressed.point())); - dragStartPositions = this.model.landmarks().selected().map( - lm => [lm.get('index'), lm.point().clone()]); + this.positionLmDrag.copy(this.viewport._localToScreen(this.lmPressed.point)); + this.dragStartPositions = this.viewport._selectedLandmarks + .map(lm => [lm.index, lm.point.clone()]); // start listening for dragging landmarks - $(document).on('mousemove.landmarkDrag', landmarkOnDrag); - $(document).one('mouseup.viewportLandmark', landmarkOnMouseUp); + $(document).on('mousemove.landmarkDrag', this.landmarkOnDrag); + $(document).one('mouseup.viewportLandmark', this.landmarkOnMouseUp); }; - var nothingPressed = () => { + nothingPressed = () => { console.log('nothing pressed!'); - $(document).one('mouseup.viewportNothing', nothingOnMouseUp); + $(document).one('mouseup.viewportNothing', this.nothingOnMouseUp); }; - var shiftPressed = () => { + shiftPressed = () => { console.log('shift pressed!'); // before anything else, disable the camera - this.cameraController.disable(); + this.viewport.cameraController.disable(); - if (!(downEvent.ctrlKey || downEvent.metaKey)) { - this.model.landmarks().deselectAll(); + if (!(this.downEvent.ctrlKey || this.downEvent.metaKey)) { + this.viewport.on.deselectAllLandmarks(); } - $(document).on('mousemove.shiftDrag', shiftOnDrag); - $(document).one('mouseup.viewportShift', shiftOnMouseUp); + $(document).on('mousemove.shiftDrag', this.shiftOnDrag); + $(document).one('mouseup.viewportShift', this.shiftOnMouseUp); }; // Catch all clicks and delegate to other handlers once user's intent // has been figured out - var onMouseDown = (event) => { + onMouseDown = atomic.atomicOperation((event) => { event.preventDefault(); - this.$el.focus(); + this.viewport.$el.focus(); - if (!this.model.landmarks()) { + if (!this.viewport._hasLandmarks) { return; } - isPressed = true; + this.isPressed = true; - downEvent = event; - onMouseDownPosition.set(event.clientX, event.clientY); + this.downEvent = event; + this.onMouseDownPosition.set(event.clientX, event.clientY); // All interactions require intersections to distinguish - intersectsWithLms = this.getIntersectsFromEvent( - event, this.sLms); + this.intersectsWithLms = this.viewport._getIntersectsFromEvent( + event, this.viewport._sLms); // note that we explicitly ask for intersects with the mesh // object as we know get intersects will use an octree if // present. - intersectsWithMesh = this.getIntersectsFromEvent(event, this.mesh); + this.intersectsWithMesh = this.viewport._getIntersectsFromEvent(event, this.viewport.mesh); // Click type, we use MouseEvent.button which is the vanilla JS way // jQuery also exposes event.which which has different bindings if (event.button === 0) { // left mouse button - if (intersectsWithLms.length > 0 && - intersectsWithMesh.length > 0) { + if (this.intersectsWithLms.length > 0 && + this.intersectsWithMesh.length > 0) { // degenerate case - which is closer? - if (intersectsWithLms[0].distance < - intersectsWithMesh[0].distance) { - landmarkPressed(event); + if (this.intersectsWithLms[0].distance < + this.intersectsWithMesh[0].distance) { + this.landmarkPressed(event); } else { // the mesh was pressed. Check for shift first. if (event.shiftKey) { - shiftPressed(); - } else if (this.model.isEditingOn() && currentTargetLm) { - meshPressed(); + this.shiftPressed(); + } else if (this.viewport._editingOn && this.currentTargetLm) { + this.meshPressed(); } else { - nothingPressed(); + this.nothingPressed(); } } - } else if (intersectsWithLms.length > 0) { - landmarkPressed(event); + } else if (this.intersectsWithLms.length > 0) { + this.landmarkPressed(event); } else if (event.shiftKey) { // shift trumps all! - shiftPressed(); + this.shiftPressed(); } else if ( - intersectsWithMesh.length > 0 && - this.model.isEditingOn() + this.intersectsWithMesh.length > 0 && + this.viewport._editingOn ) { - meshPressed(); + this.meshPressed(); } else { - nothingPressed(); + this.nothingPressed(); } } else if (event.button === 2) { // Right click if ( - intersectsWithLms.length <= 0 && - intersectsWithMesh.length > 0 + this.intersectsWithLms.length <= 0 && + this.intersectsWithMesh.length > 0 ) { - this.model.landmarks().deselectAll(); - currentTargetLm = undefined; - meshPressed(); + this.viewport.on.deselectAllLandmarks(); + this.currentTargetLm = undefined; + this.meshPressed(); } } - }; + }); // Drag Handlers // ------------------------------------------------------------------------ - landmarkOnDrag = atomic.atomicOperation((event) => { console.log("drag"); // note that positionLmDrag is set to where we started. // update where we are now and where we were var newPositionLmDrag = new THREE.Vector2( event.clientX, event.clientY); - var prevPositionLmDrag = positionLmDrag.clone(); + var prevPositionLmDrag = this.positionLmDrag.clone(); // change in this step in screen space - deltaLmDrag.subVectors(newPositionLmDrag, prevPositionLmDrag); + this.deltaLmDrag.subVectors(newPositionLmDrag, prevPositionLmDrag); // update the position - positionLmDrag.copy(newPositionLmDrag); - var selectedLandmarks = this.model.landmarks().selected(); - var lm, vScreen; - for (var i = 0; i < selectedLandmarks.length; i++) { - lm = selectedLandmarks[i]; + this.positionLmDrag.copy(newPositionLmDrag); + this.viewport._selectedLandmarks.forEach(lm => { // convert to screen coordinates - vScreen = this.localToScreen(lm.point()); + const vScreen = this.viewport._localToScreen(lm.point); // budge the screen coordinate - vScreen.add(deltaLmDrag); + vScreen.add(this.deltaLmDrag); // use the standard machinery to find intersections // note that we intersect the mesh to use the octree - intersectsWithMesh = this.getIntersects( - vScreen.x, vScreen.y, this.mesh); - if (intersectsWithMesh.length > 0) { + this.intersectsWithMesh = this.viewport._getIntersects( + vScreen.x, vScreen.y, this.viewport.mesh); + if (this.intersectsWithMesh.length > 0) { // good, we're still on the mesh. - dragged = !!dragged || true; - lm.setPoint(this.worldToLocal(intersectsWithMesh[0].point)); + this.dragged = !!this.dragged || true; + this.viewport.on.setLandmarkPointWithHistory(lm.index, + this.viewport._worldToLocal(this.intersectsWithMesh[0].point)); } else { // don't update point - it would fall off the surface. console.log("fallen off mesh"); } - } + }) }); shiftOnDrag = (event) => { @@ -263,19 +236,19 @@ export default function Handler () { // if user drags into sidebar! var newPosition = { x: event.clientX, y: event.clientY }; // clear the canvas and draw a selection rect. - this.clearCanvas(); - this.drawSelectionBox(onMouseDownPosition, newPosition); + this.viewport._clearCanvas(); + this.viewport._drawSelectionBox(this.onMouseDownPosition, newPosition); }; // Up handlers // ------------------------------------------------------------------------ shiftOnMouseUp = atomic.atomicOperation((event) => { - this.cameraController.enable(); + this.viewport.cameraController.enable(); console.log("shift:up"); - $(document).off('mousemove.shiftDrag', shiftOnDrag); - var x1 = onMouseDownPosition.x; - var y1 = onMouseDownPosition.y; + $(document).off('mousemove.shiftDrag', this.shiftOnDrag); + var x1 = this.onMouseDownPosition.x; + var y1 = this.onMouseDownPosition.y; var x2 = event.clientX; var y2 = event.clientY; var minX, maxX, minY, maxY; @@ -291,247 +264,149 @@ export default function Handler () { } // First, let's just find all the landmarks in screen space that // are within our selection. - var lms = this.lmViewsInSelectionBox(minX, minY, maxX, maxY); + var lms = this.viewport._lmViewsInSelectionBox(minX, minY, maxX, maxY); // Of these, filter out the ones which are visible (not // obscured) and select the rest - _.each(lms, (lm) => { - if (this.lmViewVisible(lm)) { - lm.model.select(); - } - }); - - this.clearCanvas(); - isPressed = false; - setGroupSelected(true); + const indexesToSelect = lms.filter(this.viewport._lmViewVisible).map(lm => lm.index); + this.viewport.on.selectLandmarks(indexesToSelect); + this.viewport._clearCanvas(); + this.isPressed = false; }); - var meshOnMouseUp = (event) => { + meshOnMouseUp = (event) => { console.log("meshPress:up"); var p; - onMouseUpPosition.set(event.clientX, event.clientY); - if (onMouseDownPosition.distanceTo(onMouseUpPosition) < 2) { + this.onMouseUpPosition.set(event.clientX, event.clientY); + if (this.onMouseDownPosition.distanceTo(this.onMouseUpPosition) < 2) { // a click on the mesh - p = intersectsWithMesh[0].point.clone(); + p = this.intersectsWithMesh[0].point.clone(); // Convert the point back into the mesh space - this.worldToLocal(p, true); + this.viewport._worldToLocal(p, true); if ( - this.model.isEditingOn() && - currentTargetLm && - currentTargetLm.group() === this.model.landmarks() && - !currentTargetLm.isEmpty() + this.viewport._editingOn && + this.currentTargetLm && + this.currentTargetLm.point !== null ) { - this.model.landmarks().setLmAt(currentTargetLm, p); - } else if (downEvent.button === 2) { - this.model.landmarks().insertNew(p); + // we are in edit mode - adjust the target point + this.viewport.on.setLandmarkPoint(this.currentTargetLm.index, p) + } else if (this.downEvent.button === 2) { + // right click - insert point. + this.viewport.on.insertNewLandmark(p) } } - - this.clearCanvas(); - isPressed = false; - setGroupSelected(false); + this.isPressed = false; + this.viewport._clearCanvas(); }; nothingOnMouseUp = (event) => { console.log("nothingPress:up"); - onMouseUpPosition.set(event.clientX, event.clientY); - if (onMouseDownPosition.distanceTo(onMouseUpPosition) < 2) { + this.onMouseUpPosition.set(event.clientX, event.clientY); + if (this.onMouseDownPosition.distanceTo(this.onMouseUpPosition) < 2) { // a click on nothing - deselect all - setGroupSelected(false); + this.viewport.on.deselectAllLandmarks(); } - - this.clearCanvas(); - isPressed = false; + this.isPressed = false; + this.viewport._clearCanvas(); }; landmarkOnMouseUp = atomic.atomicOperation((event) => { - var ctrl = downEvent.ctrlKey || downEvent.metaKey; - this.cameraController.enable(); + const ctrl = this.downEvent.ctrlKey || this.downEvent.metaKey; + this.viewport.cameraController.enable(); console.log("landmarkPress:up"); $(document).off('mousemove.landmarkDrag'); - onMouseUpPosition.set(event.clientX, event.clientY); - if (onMouseDownPosition.distanceTo(onMouseUpPosition) === 0) { + this.onMouseUpPosition.set(event.clientX, event.clientY); + if (this.onMouseDownPosition.distanceTo(this.onMouseUpPosition) === 0) { // landmark was pressed - if (lmPressedWasSelected && ctrl) { - lmPressed.deselect(); - } else if (!ctrl && !lmPressedWasSelected) { - lmPressed.selectAndDeselectRest(); - } else if (lmPressedWasSelected) { - var p = intersectsWithMesh[0].point.clone(); - this.worldToLocal(p, true); - this.model.landmarks().setLmAt(lmPressed, p); - } else if (ctrl) { - setGroupSelected(true); + if (this.lmPressed.isSelected && ctrl) { + this.viewport.on.deselectLandmarks([this.lmPressed.index]); + } else if (!ctrl && !this.lmPressed.isSelected) { + this.viewport.on.selectLandmarkAndDeselectRest(this.lmPressed.index); + } else if (this.lmPressed.isSelected) { + const p = this.intersectsWithMesh[0].point.clone(); + this.viewport._worldToLocal(p, true); + this.viewport.on.setLandmarkPoint(this.lmPressed.index, p) } - } else if (dragged) { - this.model.landmarks().selected().forEach((lm, i) => { - dragStartPositions[i].push(lm.point().clone()); + } else if (this.dragged) { + this.viewport._selectedLandmarks.forEach((lm, i) => { + this.dragStartPositions[i].push(lm.point.clone()); }); - this.model.landmarks().tracker.record(dragStartPositions); + this.viewport.on.addLandmarkHistory(this.dragStartPositions); } - this.clearCanvas(); - dragged = false; - dragStartPositions = []; - isPressed = false; + this.viewport._clearCanvas(); + this.dragged = false; + this.dragStartPositions = []; + this.isPressed = false; }); // Move handlers // ------------------------------------------------------------------------ + onMouseMove = atomic.atomicOperation((evt) => { - var onMouseMove = (evt) => { - - this.clearCanvas(); + this.viewport._clearCanvas(); - if (isPressed || - !this.model.isEditingOn() || - !this.model.landmarks() || - this.model.landmarks().isEmpty() + if (this.isPressed || + !this.viewport._editingOn || + !this.viewport._hasLandmarks || + this.viewport._allLandmarksEmpty || + this.viewport._groupModeActive ) { return null; } - - if ( - currentTargetLm && - (currentTargetLm.isEmpty() || - this.model.landmarks() !== currentTargetLm.group()) - ) { - currentTargetLm = undefined; + // only here as: + // 1. Edit mode is enabled + // 2. No group selection is made + // 3. There is at least one landmark + + if (this.currentTargetLm && this.currentTargetLm.point === null) + { + // the target point has been deleted - reset it. + // TODO decide on reset state for target landmark + this.currentTargetLm = undefined; } - intersectsWithMesh = this.getIntersectsFromEvent(evt, this.mesh); + this.intersectsWithMesh = this.viewport._getIntersectsFromEvent(evt, this.viewport.mesh); - var lmGroup = this.model.landmarks(); - - var shouldUpdate = intersectsWithMesh.length > 0 && - lmGroup && - lmGroup.landmarks; - - if (!shouldUpdate) { + if (this.intersectsWithMesh.length === 0) { + // moving the mouse off the mesh does nothing. return null; } - var mouseLoc = this.worldToLocal(intersectsWithMesh[0].point); - var previousTargetLm = currentTargetLm; - - var lms = findClosestLandmarks(lmGroup, mouseLoc, - evt.ctrlKey || evt.metaKey); - - if (lms[0] && !evt.ctrlKey) { - currentTargetLm = lms[0]; - lms = lms.slice(1, 4); - } else if (lms[0]) { - lms = lms.slice(0, 3); - } - - if (currentTargetLm && !groupSelected && lms.length > 0) { - - if (currentTargetLm !== previousTargetLm) { - // Linear operation hence protected - currentTargetLm.selectAndDeselectRest(); - } - - this.drawTargetingLines({x: evt.clientX, y: evt.clientY}, - currentTargetLm, lms); - } - }; - - // Keyboard handlers - // ------------------------------------------------------------------------ - var onKeypress = atomic.atomicOperation((evt) => { - // Only work in group selection mode - if ( - !groupSelected || !this.model.landmarks() || - evt.which < 37 || evt.which > 40 - ) { - return; - } - - // Up and down are inversed due to the way THREE handles coordinates - const directions = { - 37: [-1, 0], // Left - 38: [0, -1], // Up - 39: [1, 0], // Right - 40: [0, 1] // Down - }[evt.which]; + const mouseLoc = this.viewport._worldToLocal(this.intersectsWithMesh[0].point); - // Only operate on arrow keys - if (directions === undefined) { - return; - } - - // Set a movement of 0.5% of the screen in the suitable direction - const [x, y] = directions, - move = new THREE.Vector2(), - [dx, dy] = [.005 * window.innerWidth, .005 * window.innerHeight]; - - move.set(x * dx, y * dy); - - const ops = []; - this.model.landmarks().selected().forEach((lm) => { - const lmScreen = this.localToScreen(lm.point()); - lmScreen.add(move); + // lock only works once we have an existing target landmark + const lockEnabled = (evt.ctrlKey || evt.metaKey) && this.currentTargetLm; - intersectsWithMesh = this.getIntersects( - lmScreen.x, lmScreen.y, this.mesh); + let newTarget, nextClosest = []; + if (lockEnabled) { + // we will not change the existing target + newTarget = this.currentTargetLm; - if (intersectsWithMesh.length > 0) { - const pt = this.worldToLocal(intersectsWithMesh[0].point); - ops.push([lm.get('index'), lm.point().clone(), pt.clone()]); - lm.setPoint(pt); - } - }); - this.model.landmarks().tracker.record(ops); - }); - - // Group Selection hook - // ------------------------------------------------------------------------ + // only pick from the remaining landmarks + const candidateLandmarks = this.viewport._nonEmptyLandmarks + .filter(lm => lm.index !== this.currentTargetLm.index); + nextClosest = findClosestLandmarks(candidateLandmarks, mouseLoc, 3) - setGroupSelected = (val=true) => { - - if (!this.model.landmarks()) { - return; - } - - const _val = !!val; // Force cast to boolean - - if (_val === groupSelected) { - return; // Nothing to do here - } - - groupSelected = _val; - - if (_val) { - // Use keydown as keypress doesn't register arrows in some context - $(window).on('keydown', onKeypress); } else { - this.deselectAll(); - $(window).off('keydown', onKeypress); + // need to chose a new target landmark and new next closest + [newTarget, ...nextClosest] = findClosestLandmarks(this.viewport._nonEmptyLandmarks, mouseLoc, 4) } - this.clearCanvas(); - }; + // Remember, we know there are >= 1 landmarks, so we always have a newTarget. + // Draw it and the next closest on the UI.... + this.viewport._drawTargetingLines({x: evt.clientX, y: evt.clientY}, + newTarget, nextClosest); - var completeGroupSelection = () => { + // and if we have a change of new target, update the selection + if (!this.currentTargetLm || newTarget.index !== this.currentTargetLm.index) { + // target has changed, which triggers a change in selection + this.viewport.on.selectLandmarkAndDeselectRest(newTarget.index) - if (!this.model.landmarks()) { - return; + // finally, update the current target lm for next time around. + this.currentTargetLm = newTarget; } - - this.model.landmarks().completeGroups(); - - setGroupSelected(true); - }; - - return { - // State management - setGroupSelected: atomic.atomicOperation(setGroupSelected), - completeGroupSelection: completeGroupSelection, - - // Exposed handlers - onMouseDown: atomic.atomicOperation(onMouseDown), - onMouseMove: atomic.atomicOperation(onMouseMove) - }; + }); } diff --git a/src/js/app/view/viewport/index.js b/src/js/app/view/viewport/index.js index e8fcb10e..79b2d2dc 100644 --- a/src/js/app/view/viewport/index.js +++ b/src/js/app/view/viewport/index.js @@ -1,16 +1,16 @@ 'use strict'; import _ from 'underscore'; -import Backbone from 'backbone'; import $ from 'jquery'; import THREE from 'three'; import atomic from '../../model/atomic'; -import * as octree from '../../model/octree'; +import * as octree from './octree'; -import CameraController from './camera'; +import { CameraController } from './camera'; import Handler from './handler'; -import { LandmarkConnectionTHREEView, LandmarkTHREEView } from './elements'; +import { LandmarkConnectionTHREEView } from './elements/connection' +import { LandmarkTHREEView } from './elements/landmark' // clear colour for both the main view and PictureInPicture const CLEAR_COLOUR = 0xEEEEEE; @@ -24,14 +24,28 @@ const PIP_HEIGHT = 300; const MESH_SCALE = 1.0; -export default Backbone.View.extend({ +function _initialBoundingBox() { + return {minX: 999999, minY: 999999, maxX: 0, maxY: 0}; +} - el: '#canvas', - id: 'canvas', +// We are trying to move towards the whole viewport module being a standalone black box that +// has no dependencies beyond THREE and our octree. As part of this effort, we refactor out +// the Viewport core code into a standalone class with minimal interaction with Backbone. +export class Viewport { + + constructor(meshMode, on) { + // all our callbacks are stored under the on namespace. + this.on = on; + + this.meshMode = meshMode; + this.connectivityOn = true; + + this.el = document.getElementById('canvas'); + this.$el = $('#canvas'); - initialize: function () { // ----- CONFIGURATION ----- // - this.meshScale = MESH_SCALE; // The radius of the mesh's bounding sphere + this._meshScale = MESH_SCALE; // The radius of the mesh's bounding sphere + this._lmSize = 1; // Disable context menu on viewport related elements $('canvas').on("contextmenu", function(e){ @@ -42,10 +56,6 @@ export default Backbone.View.extend({ e.preventDefault(); }); - // TODO bind all methods on the Viewport - _.bindAll(this, 'resize', 'render', 'changeMesh', - 'mousedownHandler', 'update', 'lmViewsInSelectionBox'); - // ----- DOM ----- // // We have three DOM concerns: // @@ -62,104 +72,103 @@ export default Backbone.View.extend({ // we need to track the pixel ratio of this device (i.e. is it a // HIDPI/retina display?) - this.pixelRatio = window.devicePixelRatio || 1; + this._pixelRatio = window.devicePixelRatio || 1; // Get a hold on the overlay canvas and its context (note we use the // id - the Viewport should be passed the canvas element on // construction) - this.canvas = document.getElementById(this.id); - this.ctx = this.canvas.getContext('2d'); + this._canvas = document.getElementById('canvas'); + this._ctx = this._canvas.getContext('2d'); // we hold a separate canvas for the PIP decoration - grab it - this.pipCanvas = document.getElementById('pipCanvas'); - this.pipCtx = this.pipCanvas.getContext('2d'); + this._pipCanvas = document.getElementById('pipCanvas'); + this._pipCtx = this._pipCanvas.getContext('2d'); // style the PIP canvas on initialization - this.pipCanvas.style.position = 'fixed'; - this.pipCanvas.style.zIndex = 0; - this.pipCanvas.style.width = PIP_WIDTH + 'px'; - this.pipCanvas.style.height = PIP_HEIGHT + 'px'; - this.pipCanvas.width = PIP_WIDTH * this.pixelRatio; - this.pipCanvas.height = PIP_HEIGHT * this.pixelRatio; - this.pipCanvas.style.left = this.pipBounds().x + 'px'; - - // To compensate for rentina displays we have to manually - // scale our contexts up by the pixel ration. To conteract this (so we + this._pipCanvas.style.position = 'fixed'; + this._pipCanvas.style.zIndex = 0; + this._pipCanvas.style.width = PIP_WIDTH + 'px'; + this._pipCanvas.style.height = PIP_HEIGHT + 'px'; + this._pipCanvas.width = PIP_WIDTH * this._pixelRatio; + this._pipCanvas.height = PIP_HEIGHT * this._pixelRatio; + this._pipCanvas.style.left = this._pipBounds().x + 'px'; + + // To compensate for retina displays we have to manually + // scale our contexts up by the pixel ratio. To counteract this (so we // can work in 'normal' pixel units) add a global transform to the // canvas contexts we are holding on to. - this.pipCtx.setTransform(this.pixelRatio, 0, 0, this.pixelRatio, 0, 0); - this.ctx.setTransform(this.pixelRatio, 0, 0, this.pixelRatio, 0, 0); + this._pipCtx.setTransform(this._pixelRatio, 0, 0, this._pixelRatio, 0, 0); + this._ctx.setTransform(this._pixelRatio, 0, 0, this._pixelRatio, 0, 0); // Draw the PIP window - we only do this once. - this.pipCtx.strokeStyle = '#ffffff'; + this._pipCtx.strokeStyle = '#ffffff'; // vertical line - this.pipCtx.beginPath(); - this.pipCtx.moveTo(PIP_WIDTH / 2, PIP_HEIGHT * 0.4); - this.pipCtx.lineTo(PIP_WIDTH / 2, PIP_HEIGHT * 0.6); + this._pipCtx.beginPath(); + this._pipCtx.moveTo(PIP_WIDTH / 2, PIP_HEIGHT * 0.4); + this._pipCtx.lineTo(PIP_WIDTH / 2, PIP_HEIGHT * 0.6); // horizontal line - this.pipCtx.moveTo(PIP_WIDTH * 0.4, PIP_HEIGHT / 2); - this.pipCtx.lineTo(PIP_WIDTH * 0.6, PIP_HEIGHT / 2); - this.pipCtx.stroke(); + this._pipCtx.moveTo(PIP_WIDTH * 0.4, PIP_HEIGHT / 2); + this._pipCtx.lineTo(PIP_WIDTH * 0.6, PIP_HEIGHT / 2); + this._pipCtx.stroke(); - this.pipCtx.setLineDash([2, 2]); - this.pipCtx.strokeRect(0, 0, PIP_WIDTH, PIP_HEIGHT); + this._pipCtx.setLineDash([2, 2]); + this._pipCtx.strokeRect(0, 0, PIP_WIDTH, PIP_HEIGHT); // hide the pip decoration - should only be shown when in orthgraphic // mode. - this.pipCanvas.style.display = 'none'; + this._pipCanvas.style.display = 'none'; // to be efficient we want to track what parts of the canvas we are // drawing into each frame. This way we only need clear the relevant // area of the canvas which is a big perf win. - // see this.updateCanvasBoundingBox() for usage. - this.ctxBox = this.initialBoundingBox(); + // see this._updateCanvasBoundingBox() for usage. + this._ctxBox = _initialBoundingBox(); // ------ SCENE GRAPH CONSTRUCTION ----- // - this.scene = new THREE.Scene(); + this._scene = new THREE.Scene(); // we use an initial top level to handle the absolute positioning of // the mesh and landmarks. Rotation and scale are applied to the - // sMeshAndLms node directly. - this.sScaleRotate = new THREE.Object3D(); - this.sTranslate = new THREE.Object3D(); + // _sMeshAndLms node directly. + this._sScaleRotate = new THREE.Object3D(); + this._sTranslate = new THREE.Object3D(); // ----- SCENE: MODEL AND LANDMARKS ----- // - // sMeshAndLms stores the mesh and landmarks in the meshes original + // _sMeshAndLms stores the mesh and landmarks in the meshes original // coordinates. This is always transformed to the unit sphere for // consistency of camera. - this.sMeshAndLms = new THREE.Object3D(); - // sLms stores the scene landmarks. This is a useful container to - // get at all landmarks in one go, and is a child of sMeshAndLms - this.sLms = new THREE.Object3D(); - this.sMeshAndLms.add(this.sLms); - // sMesh is the parent of the mesh itself in the THREE scene. + this._sMeshAndLms = new THREE.Object3D(); + // _sLms stores the scene landmarks. This is a useful container to + // get at all landmarks in one go, and is a child of _sMeshAndLms + this._sLms = new THREE.Object3D(); + this._sMeshAndLms.add(this._sLms); + // _sMesh is the parent of the mesh itself in the THREE scene. // This will only ever have one child (the mesh). - // Child of sMeshAndLms - this.sMesh = new THREE.Object3D(); - this.sMeshAndLms.add(this.sMesh); - this.sTranslate.add(this.sMeshAndLms); - this.sScaleRotate.add(this.sTranslate); - this.scene.add(this.sScaleRotate); + // Child of _sMeshAndLms + this._sMesh = new THREE.Object3D(); + this._sMeshAndLms.add(this._sMesh); + this._sTranslate.add(this._sMeshAndLms); + this._sScaleRotate.add(this._sTranslate); + this._scene.add(this._sScaleRotate); // ----- SCENE: CAMERA AND DIRECTED LIGHTS ----- // - // sCamera holds the camera, and (optionally) any + // _sCamera holds the camera, and (optionally) any // lights that track with the camera as children - this.sOCam = new THREE.OrthographicCamera( -1, 1, 1, -1, 0, 20); - this.sOCamZoom = new THREE.OrthographicCamera( -1, 1, 1, -1, 0, 20); - this.sPCam = new THREE.PerspectiveCamera(50, 1, 0.02, 20); + this._sOCam = new THREE.OrthographicCamera( -1, 1, 1, -1, 0, 20); + this._sOCamZoom = new THREE.OrthographicCamera( -1, 1, 1, -1, 0, 20); + this._sPCam = new THREE.PerspectiveCamera(50, 1, 0.02, 20); // start with the perspective camera as the main one - this.sCamera = this.sPCam; + this._sCamera = this._sPCam; // create the cameraController to look after all camera state. this.cameraController = CameraController( - this.sPCam, this.sOCam, this.sOCamZoom, - this.el, this.model.imageMode()); + this._sPCam, this._sOCam, this._sOCamZoom, + this.el); - // when the camera updates, render - this.cameraController.on('change', this.update); + this.cameraController.onChange = this._update; - if (!this.model.meshMode()) { + if (!this.meshMode) { // for images, default to orthographic camera // (note that we use toggle to make sure the UI gets updated) this.toggleCamera(); @@ -170,53 +179,49 @@ export default Backbone.View.extend({ // ----- SCENE: GENERAL LIGHTING ----- // // TODO make lighting customizable // TODO no spot light for images - this.sLights = new THREE.Object3D(); - var pointLightLeft = new THREE.PointLight(0x404040, 1, 0); + this._sLights = new THREE.Object3D(); + const pointLightLeft = new THREE.PointLight(0x404040, 1, 0); pointLightLeft.position.set(-100, 0, 100); - this.sLights.add(pointLightLeft); - var pointLightRight = new THREE.PointLight(0x404040, 1, 0); + this._sLights.add(pointLightLeft); + const pointLightRight = new THREE.PointLight(0x404040, 1, 0); pointLightRight.position.set(100, 0, 100); - this.sLights.add(pointLightRight); - this.scene.add(this.sLights); + this._sLights.add(pointLightRight); + this._scene.add(this._sLights); // add a soft white ambient light - this.sLights.add(new THREE.AmbientLight(0x404040)); + this._sLights.add(new THREE.AmbientLight(0x404040)); - this.renderer = new THREE.WebGLRenderer( + this._renderer = new THREE.WebGLRenderer( { antialias: false, alpha: false }); - this.renderer.setPixelRatio(window.devicePixelRatio || 1); - this.renderer.setClearColor(CLEAR_COLOUR, 1); - this.renderer.autoClear = false; + this._renderer.setPixelRatio(window.devicePixelRatio || 1); + this._renderer.setClearColor(CLEAR_COLOUR, 1); + this._renderer.autoClear = false; // attach the render on the element we picked out earlier - this.$webglel.html(this.renderer.domElement); + this.$webglel.html(this._renderer.domElement); // we build a second scene for various helpers we may need // (intersection planes) and for connectivity information (so it // shows through) - this.sceneHelpers = new THREE.Scene(); + this._sceneHelpers = new THREE.Scene(); - // sLmsConnectivity is used to store the connectivity representation + // _sLmsConnectivity is used to store the connectivity representation // of the mesh. Note that we want - this.sLmsConnectivity = new THREE.Object3D(); + this._sLmsConnectivity = new THREE.Object3D(); // we want to replicate the mesh scene graph in the scene helpers, so we can // have show-though connectivity.. - this.shScaleRotate = new THREE.Object3D(); - this.sHTranslate = new THREE.Object3D(); - this.shMeshAndLms = new THREE.Object3D(); - this.shMeshAndLms.add(this.sLmsConnectivity); - this.sHTranslate.add(this.shMeshAndLms); - this.shScaleRotate.add(this.sHTranslate); - this.sceneHelpers.add(this.shScaleRotate); - - // add mesh if there already is one present (we could have missed a - // backbone callback). - this.changeMesh(); - - // make an empty list of landmark views - this.landmarkViews = []; - this.connectivityViews = []; + this._shScaleRotate = new THREE.Object3D(); + this._sHTranslate = new THREE.Object3D(); + this._shMeshAndLms = new THREE.Object3D(); + this._shMeshAndLms.add(this._sLmsConnectivity); + this._sHTranslate.add(this._shMeshAndLms); + this._shScaleRotate.add(this._sHTranslate); + this._sceneHelpers.add(this._shScaleRotate); + + // store the views that we will later create + this._landmarkViews = []; + this._connectivityViews = []; // Tools for moving between screen and world coordinates - this.ray = new THREE.Raycaster(); + this._ray = new THREE.Raycaster(); // ----- MOUSE HANDLER ----- // // There is quite a lot of finicky state in handling the mouse @@ -224,35 +229,17 @@ export default Backbone.View.extend({ // We wrap all this complexity up in a closure so it can enjoy access // to the general viewport state without leaking it's state all over // the place. - this._handler = Handler.apply(this); + this._handler = new Handler(this); + this.el.addEventListener('mousedown', this._handler.onMouseDown); // ----- BIND HANDLERS ----- // - window.addEventListener('resize', this.resize, false); - this.listenTo(this.model, 'newMeshAvailable', this.changeMesh); - this.listenTo(this.model, "change:landmarks", this.changeLandmarks); - - this.showConnectivity = true; - this.listenTo( - this.model, - 'change:connectivityOn', - this.updateConnectivityDisplay - ); - this.updateConnectivityDisplay(); - - this.listenTo( - this.model, 'change:editingOn', this.updateEditingDisplay); - this.updateEditingDisplay(); - - // Reset helper views on wheel to keep scale - // this.$el.on('wheel', () => { - // this.clearCanvas(); - // }); - - this.listenTo(atomic, "change:ATOMIC_OPERATION", this.batchHandler); - + window.addEventListener('resize', this._resize, false); // trigger resize to initially size the viewport // this will also clearCanvas (will draw context box if needed) - this.resize(); + this._resize(); + + // TODO this probably goes away once we remove Backbone from the view + atomic.on("change:ATOMIC_OPERATION", this._batchHandler); // register for the animation loop animate(); @@ -263,448 +250,450 @@ export default Backbone.View.extend({ //stats.update(); } - this.$container.on('groupSelected', () => { - this._handler.setGroupSelected(true); + this.$container.on('resetCamera', () => { + this.resetCamera(); }); + } - this.$container.on('groupDeselected', () => { - this._handler.setGroupSelected(false); - }); + setLandmarksAndConnectivity = atomic.atomicOperation((landmarks, connectivity) => { + console.log('Viewport: landmarks have changed'); + this._landmarks = landmarks; + this._connectivity = connectivity; - this.$container.on('completeGroupSelection', () => { - this._handler.completeGroupSelection(); - }); + // 1. Dispose of all landmark and connectivity views + this._landmarkViews.forEach(lmView => lmView.dispose()); + this._connectivityViews.forEach(connView => connView.dispose()); - this.$container.on('resetCamera', () => { - this.resetCamera(); + // 2. Build a fresh set of views + this._landmarkViews = this._landmarks.map(lm => + new LandmarkTHREEView(lm, + { + onCreate: symbol => this._sLms.add(symbol), + onDispose: symbol => this._sLms.remove(symbol) + }) + ); + this._connectivityViews = this._connectivity.map(([a, b]) => + new LandmarkConnectionTHREEView(this._landmarks[a], this._landmarks[b], + { + onCreate: symbol => this._sLmsConnectivity.add(symbol), + onDispose: symbol => this._sLmsConnectivity.remove(symbol) + }) + ); + + // 3. Reset the handler state + this._handler.resetLandmarks() + + }); + + updateLandmarks = atomic.atomicOperation(landmarks => { + landmarks.forEach(lm => { + this._landmarks[lm.index] = lm; + this._landmarkViews[lm.index].render(lm); }); - }, - width: function () { - return this.$container[0].offsetWidth; - }, + // Finally go through all connectivity views and update them + this._connectivityViews.forEach((view, i) => { + const [a, b] = this._connectivity[i]; + view.render(this._landmarks[a], this._landmarks[b]) + }); - height: function () { - return this.$container[0].offsetHeight; - }, + this._update() + }); - changeMesh: function () { - var meshPayload, mesh, up, front; - console.log('Viewport:changeMesh - memory before: ' + this.memoryString()); + setMesh = (mesh, up, front) => { + console.log('Viewport:setMesh - memory before: ' + this.memoryString()); // firstly, remove any existing mesh this.removeMeshIfPresent(); - meshPayload = this.model.mesh(); - if (meshPayload === null) { - return; - } - mesh = meshPayload.mesh; - up = meshPayload.up; - front = meshPayload.front; this.mesh = mesh; - if(mesh.geometry instanceof THREE.BufferGeometry) { + if (mesh.geometry instanceof THREE.BufferGeometry) { // octree only makes sense if we are dealing with a true mesh // (not images). Such meshes are always BufferGeometry instances. this.octree = octree.octreeForBufferGeometry(mesh.geometry); } - this.sMesh.add(mesh); - // Now we need to rescale the sMeshAndLms to fit in the unit sphere + this._sMesh.add(mesh); + // Now we need to rescale the _sMeshAndLms to fit in the unit sphere // First, the scale - this.meshScale = mesh.geometry.boundingSphere.radius; - var s = 1.0 / this.meshScale; - this.sScaleRotate.scale.set(s, s, s); - this.shScaleRotate.scale.set(s, s, s); - this.sScaleRotate.up.copy(up); - this.shScaleRotate.up.copy(up); - this.sScaleRotate.lookAt(front.clone()); - this.shScaleRotate.lookAt(front.clone()); + this._meshScale = mesh.geometry.boundingSphere.radius; + var s = 1.0 / this._meshScale; + this._sScaleRotate.scale.set(s, s, s); + this._shScaleRotate.scale.set(s, s, s); + this._sScaleRotate.up.copy(up); + this._shScaleRotate.up.copy(up); + this._sScaleRotate.lookAt(front.clone()); + this._shScaleRotate.lookAt(front.clone()); // translation var t = mesh.geometry.boundingSphere.center.clone(); t.multiplyScalar(-1.0); - this.sTranslate.position.copy(t); - this.sHTranslate.position.copy(t); - this.update(); - }, + this._sTranslate.position.copy(t); + this._sHTranslate.position.copy(t); + this._update(); + }; - removeMeshIfPresent: function () { + setLandmarkSize = (lmSize) => { + this._lmSize = lmSize; + }; + + removeMeshIfPresent = () => { if (this.mesh !== null) { - this.sMesh.remove(this.mesh); + this._sMesh.remove(this.mesh); this.mesh = null; this.octree = null; } - }, + }; - memoryString: function () { - return 'geo:' + this.renderer.info.memory.geometries + - ' tex:' + this.renderer.info.memory.textures + - ' prog:' + this.renderer.info.memory.programs; - }, + memoryString = () => { + return 'geo:' + this._renderer.info.memory.geometries + + ' tex:' + this._renderer.info.memory.textures + + ' prog:' + this._renderer.info.memory.programs; + }; - // this is called whenever there is a state change on the THREE scene - update: function () { - if (!this.renderer) { - return; - } - // if in batch mode - noop. - if (atomic.atomicOperationUnderway()) { - return; - } - //console.log('Viewport:update'); - // 1. Render the main viewport - var w, h; - w = this.width(); - h = this.height(); - this.renderer.setViewport(0, 0, w, h); - this.renderer.setScissor(0, 0, w, h); - this.renderer.enableScissorTest(true); - this.renderer.clear(); - this.renderer.render(this.scene, this.sCamera); - - if (this.showConnectivity) { - this.renderer.clearDepth(); // clear depth buffer - // and render the connectivity - this.renderer.render(this.sceneHelpers, this.sCamera); - } - - // 2. Render the PIP image if in orthographic mode - if (this.sCamera === this.sOCam) { - var b = this.pipBounds(); - this.renderer.setClearColor(CLEAR_COLOUR_PIP, 1); - this.renderer.setViewport(b.x, b.y, b.width, b.height); - this.renderer.setScissor(b.x, b.y, b.width, b.height); - this.renderer.enableScissorTest(true); - this.renderer.clear(); - // render the PIP image - this.renderer.render(this.scene, this.sOCamZoom); - if (this.showConnectivity) { - this.renderer.clearDepth(); // clear depth buffer - // and render the connectivity - this.renderer.render(this.sceneHelpers, this.sOCamZoom); - } - this.renderer.setClearColor(CLEAR_COLOUR, 1); - } - }, - - toggleCamera: function () { + toggleCamera = () => { // check what the current setting is - var currentlyPerspective = (this.sCamera === this.sPCam); + var currentlyPerspective = (this._sCamera === this._sPCam); if (currentlyPerspective) { // going to orthographic - start listening for pip updates - this.listenTo(this.cameraController, "changePip", this.update); - this.sCamera = this.sOCam; + this.cameraController.onChangePip = this._update; + this._sCamera = this._sOCam; // hide the pip decoration - this.pipCanvas.style.display = null; + this._pipCanvas.style.display = null; } else { // leaving orthographic - stop listening to pip calls. - this.stopListening(this.cameraController, "changePip"); - this.sCamera = this.sPCam; + this.cameraController.onChangePip = null; + this._sCamera = this._sPCam; // show the pip decoration - this.pipCanvas.style.display = 'none'; + this._pipCanvas.style.display = 'none'; } // clear the canvas and re-render our state - this.clearCanvas(); - this.update(); - }, - - pipBounds: function () { - var w = this.width(); - var h = this.height(); - var maxX = w; - var maxY = h; - var minX = maxX - PIP_WIDTH; - var minY = maxY - PIP_HEIGHT; - return {x: minX, y: minY, width: PIP_WIDTH, height: PIP_HEIGHT}; - }, + this._clearCanvas(); + this._update(); + }; - resetCamera: function () { + resetCamera = () => { // reposition the cameras and focus back to the starting point. - const v = this.model.meshMode() ? MESH_MODE_STARTING_POSITION : - IMAGE_MODE_STARTING_POSITION; + const v = this.meshMode ? MESH_MODE_STARTING_POSITION : + IMAGE_MODE_STARTING_POSITION; this.cameraController.reset( - v, this.scene.position, this.model.meshMode()); - this.update(); - }, - - // Event Handlers - // ========================================================================= - - events: { - 'mousedown': "mousedownHandler" - }, + v, this._scene.position, this.meshMode); + this._update(); + }; - mousedownHandler: function (event) { - event.preventDefault(); - this._handler.onMouseDown(event); - }, + updateConnectivityDisplay = (isConnectivityOn) => { + this.connectivityOn = isConnectivityOn; + this._update(); + }; - updateConnectivityDisplay: atomic.atomicOperation(function () { - this.showConnectivity = this.model.isConnectivityOn(); - }), - - updateEditingDisplay: atomic.atomicOperation(function () { - this.editingOn = this.model.isEditingOn(); - this.clearCanvas(); - this._handler.setGroupSelected(false); + updateEditingDisplay = atomic.atomicOperation(isEditModeOn => { + this._editingOn = isEditModeOn; + this._clearCanvas(); + this.on.deselectAllLandmarks(); // Manually bind to avoid useless function call (even with no effect) - if (this.editingOn) { + if (this._editingOn) { this.$el.on('mousemove', this._handler.onMouseMove); } else { this.$el.off('mousemove', this._handler.onMouseMove); } - }), + }); + + budgeLandmarks = atomic.atomicOperation(vector => { + + // Set a movement of 0.5% of the screen in the suitable direction + const [x, y] = vector, + move = new THREE.Vector2(), + [dx, dy] = [.005 * window.innerWidth, .005 * window.innerHeight]; - deselectAll: function () { - const lms = this.model.get('landmarks'); - if (lms) { - lms.deselectAll(); + move.set(x * dx, y * dy); + + const ops = []; + this._selectedLandmarks.forEach((lm) => { + const lmScreen = this._localToScreen(lm.point); + lmScreen.add(move); + + const intersectsWithMesh = this._getIntersects(lmScreen.x, lmScreen.y, this.mesh); + + if (intersectsWithMesh.length > 0) { + const pt = this._worldToLocal(intersectsWithMesh[0].point); + ops.push([lm.index, lm.point.clone(), pt.clone()]); + this.on.setLandmarkPointWithHistory(lm.index, pt); + } + }); + this.on.addLandmarkHistory(ops); + }); + + get _hasLandmarks() { + return this._landmarks !== null && this._landmarks !== undefined + } + + get _nonEmptyLandmarks() { + return this._landmarks.filter(lm => lm.point !== null) + } + + get _selectedLandmarks() { + return this._landmarks.filter(lm => lm.isSelected) + } + + get _groupModeActive() { + return this._selectedLandmarks.length > 1 + } + + get _allLandmarksEmpty() { + return this._nonEmptyLandmarks.length === 0 + } + + _width = () => { + return this.$container[0].offsetWidth; + }; + + _height = () => { + return this.$container[0].offsetHeight; + }; + + // this is called whenever there is a state change on the THREE _scene + _update = () => { + if (!this._renderer) { + return; + } + // if in batch mode - dont render unnecessarily + if (atomic.atomicOperationUnderway()) { + return; } - }, - resize: function () { + // 1. Before we do any rendering ensure the landmarks are the right size + const s = this._lmSize * this._meshScale; + this._sLms.children.forEach(v => v.scale.x !== s ? v.scale.set(s, s, s) : null); + + // 2. Render the main viewport... + const w = this._width(); + const h = this._height(); + this._renderer.setViewport(0, 0, w, h); + this._renderer.setScissor(0, 0, w, h); + this._renderer.enableScissorTest(true); + this._renderer.clear(); + this._renderer.render(this._scene, this._sCamera); + + if (this.connectivityOn) { + // clear depth buffer + this._renderer.clearDepth(); + // and render the connectivity + this._renderer.render(this._sceneHelpers, this._sCamera); + } + + // 3. Render the PIP image if in orthographic mode + if (this._sCamera === this._sOCam) { + var b = this._pipBounds(); + this._renderer.setClearColor(CLEAR_COLOUR_PIP, 1); + this._renderer.setViewport(b.x, b.y, b.width, b.height); + this._renderer.setScissor(b.x, b.y, b.width, b.height); + this._renderer.enableScissorTest(true); + this._renderer.clear(); + // render the PIP image + this._renderer.render(this._scene, this._sOCamZoom); + if (this.connectivityOn) { + this._renderer.clearDepth(); // clear depth buffer + // and render the connectivity + this._renderer.render(this._sceneHelpers, this._sOCamZoom); + } + this._renderer.setClearColor(CLEAR_COLOUR, 1); + } + }; + + _pipBounds = () => { + var w = this._width(); + var h = this._height(); + var maxX = w; + var maxY = h; + var minX = maxX - PIP_WIDTH; + var minY = maxY - PIP_HEIGHT; + return {x: minX, y: minY, width: PIP_WIDTH, height: PIP_HEIGHT}; + }; + + _resize = () => { var w, h; - w = this.width(); - h = this.height(); + w = this._width(); + h = this._height(); // ask the camera controller to update the cameras appropriately this.cameraController.resize(w, h); // update the size of the renderer and the canvas - this.renderer.setSize(w, h); + this._renderer.setSize(w, h); // scale the canvas and change its CSS width/height to make it high res. // note that this means the canvas will be 2x the size of the screen // with 2x displays - that's OK though, we know this is a FullScreen // CSS class and so will be made to fit in the existing window by other // constraints. - this.canvas.width = w * this.pixelRatio; - this.canvas.height = h * this.pixelRatio; + this._canvas.width = w * this._pixelRatio; + this._canvas.height = h * this._pixelRatio; // make sure our global transform for the general context accounts for // the pixelRatio - this.ctx.setTransform(this.pixelRatio, 0, 0, this.pixelRatio, 0, 0); + this._ctx.setTransform(this._pixelRatio, 0, 0, this._pixelRatio, 0, 0); - // move the pipCanvas to the right place - this.pipCanvas.style.left = this.pipBounds().x + 'px'; - this.update(); - }, + // move the _pipCanvas to the right place + this._pipCanvas.style.left = this._pipBounds().x + 'px'; + this._update(); + }; - batchHandler: function (dispatcher) { + _batchHandler = (dispatcher) => { if (dispatcher.atomicOperationFinished()) { // just been turned off - trigger an update. - this.update(); - } - }, - - changeLandmarks: atomic.atomicOperation(function () { - console.log('Viewport: landmarks have changed'); - var that = this; - - // 1. Dispose of all landmark and connectivity views - _.map(this.landmarkViews, function (lmView) { - lmView.dispose(); - }); - _.map(this.connectivityViews, function (connView) { - connView.dispose(); - }); - - // 2. Build a fresh set of views - clear any existing views - this.landmarkViews = []; - this.connectivityViews = []; - - var landmarks = this.model.get('landmarks'); - if (landmarks === null) { - // no actual landmarks available - return - // TODO when can this happen?! - return; + this._update(); } - landmarks.landmarks.map(function (lm) { - that.landmarkViews.push(new LandmarkTHREEView( - { - model: lm, - viewport: that - })); - }); - landmarks.connectivity.map(function (ab) { - that.connectivityViews.push(new LandmarkConnectionTHREEView( - { - model: [landmarks.landmarks[ab[0]], - landmarks.landmarks[ab[1]]], - viewport: that - })); - }); - - }), + }; // 2D Canvas helper functions // ======================================================================== - updateCanvasBoundingBox: function(point) { + _updateCanvasBoundingBox = (point) => { // update the canvas bounding box to account for this new point - this.ctxBox.minX = Math.min(this.ctxBox.minX, point.x); - this.ctxBox.minY = Math.min(this.ctxBox.minY, point.y); - this.ctxBox.maxX = Math.max(this.ctxBox.maxX, point.x); - this.ctxBox.maxY = Math.max(this.ctxBox.maxY, point.y); - }, + this._ctxBox.minX = Math.min(this._ctxBox.minX, point.x); + this._ctxBox.minY = Math.min(this._ctxBox.minY, point.y); + this._ctxBox.maxX = Math.max(this._ctxBox.maxX, point.x); + this._ctxBox.maxY = Math.max(this._ctxBox.maxY, point.y); + }; - drawSelectionBox: function (mouseDown, mousePosition) { + _drawSelectionBox = (mouseDown, mousePosition) => { var x = mouseDown.x; var y = mouseDown.y; var dx = mousePosition.x - x; var dy = mousePosition.y - y; - this.ctx.strokeRect(x, y, dx, dy); + this._ctx.strokeRect(x, y, dx, dy); // update the bounding box - this.updateCanvasBoundingBox(mouseDown); - this.updateCanvasBoundingBox(mousePosition); - }, + this._updateCanvasBoundingBox(mouseDown); + this._updateCanvasBoundingBox(mousePosition); + }; - drawTargetingLines: function (point, targetLm, secondaryLms) { + _drawTargetingLines = (point, targetLm, secondaryLms) => { - this.updateCanvasBoundingBox(point); + this._updateCanvasBoundingBox(point); // first, draw the secondary lines - this.ctx.save(); - this.ctx.strokeStyle = "#7ca5fe"; - this.ctx.setLineDash([5, 15]); - - this.ctx.beginPath(); - secondaryLms.forEach((lm) => { - var lmPoint = this.localToScreen(lm.point()); - this.updateCanvasBoundingBox(lmPoint); - this.ctx.moveTo(lmPoint.x, lmPoint.y); - this.ctx.lineTo(point.x, point.y); + this._ctx.save(); + this._ctx.strokeStyle = "#7ca5fe"; + this._ctx.setLineDash([5, 15]); + + this._ctx.beginPath(); + secondaryLms.forEach(lm => { + var lmPoint = this._localToScreen(lm.point); + this._updateCanvasBoundingBox(lmPoint); + this._ctx.moveTo(lmPoint.x, lmPoint.y); + this._ctx.lineTo(point.x, point.y); }); - this.ctx.stroke(); - this.ctx.restore(); + this._ctx.stroke(); + this._ctx.restore(); // now, draw the primary line - this.ctx.strokeStyle = "#01e6fb"; - - this.ctx.beginPath(); - var targetPoint = this.localToScreen(targetLm.point()); - this.updateCanvasBoundingBox(targetPoint); - this.ctx.moveTo(targetPoint.x, targetPoint.y); - this.ctx.lineTo(point.x, point.y); - this.ctx.stroke(); - }, - - clearCanvas: function () { - if (_.isEqual(this.ctxBox, this.initialBoundingBox())) { + this._ctx.strokeStyle = "#01e6fb"; + + this._ctx.beginPath(); + const targetPoint = this._localToScreen(targetLm.point); + this._updateCanvasBoundingBox(targetPoint); + this._ctx.moveTo(targetPoint.x, targetPoint.y); + this._ctx.lineTo(point.x, point.y); + this._ctx.stroke(); + }; + + _clearCanvas = () => { + if (_.isEqual(this._ctxBox, _initialBoundingBox())) { // there has been no change to the canvas - no need to clear return null; } // we only want to clear the area of the canvas that we dirtied - // since the last clear. The ctxBox object tracks this - var p = 3; // padding to be added to bounding box - var minX = Math.max(Math.floor(this.ctxBox.minX) - p, 0); - var minY = Math.max(Math.floor(this.ctxBox.minY) - p, 0); - var maxX = Math.ceil(this.ctxBox.maxX) + p; - var maxY = Math.ceil(this.ctxBox.maxY) + p; - var width = maxX - minX; - var height = maxY - minY; - this.ctx.clearRect(minX, minY, width, height); + // since the last clear. The _ctxBox object tracks this + const p = 3; // padding to be added to bounding box + const minX = Math.max(Math.floor(this._ctxBox.minX) - p, 0); + const minY = Math.max(Math.floor(this._ctxBox.minY) - p, 0); + const maxX = Math.ceil(this._ctxBox.maxX) + p; + const maxY = Math.ceil(this._ctxBox.maxY) + p; + const width = maxX - minX; + const height = maxY - minY; + this._ctx.clearRect(minX, minY, width, height); // reset the tracking of the context bounding box tracking. - this.ctxBox = this.initialBoundingBox(); - }, - - initialBoundingBox: function () { - return {minX: 999999, minY: 999999, maxX: 0, maxY: 0}; - }, + this._ctxBox = _initialBoundingBox(); + }; // Coordinates and intersection helpers // ========================================================================= - getIntersects: function (x, y, object) { + _getIntersects = (x, y, object) => { if (object === null || object.length === 0) { return []; } - var vector = new THREE.Vector3((x / this.width()) * 2 - 1, - -(y / this.height()) * 2 + 1, 0.5); + const vector = new THREE.Vector3((x / this._width()) * 2 - 1, + -(y / this._height()) * 2 + 1, 0.5); - if (this.sCamera === this.sPCam) { + if (this._sCamera === this._sPCam) { // perspective selection vector.setZ(0.5); - vector.unproject(this.sCamera); - this.ray.set(this.sCamera.position, vector.sub(this.sCamera.position).normalize()); + vector.unproject(this._sCamera); + this._ray.set(this._sCamera.position, vector.sub(this._sCamera.position).normalize()); } else { // orthographic selection vector.setZ(-1); - vector.unproject(this.sCamera); + vector.unproject(this._sCamera); var dir = new THREE.Vector3(0, 0, -1) - .transformDirection(this.sCamera.matrixWorld); - this.ray.set(vector, dir); + .transformDirection(this._sCamera.matrixWorld); + this._ray.set(vector, dir); } if (object === this.mesh && this.octree) { // we can use the octree to intersect the mesh efficiently. - return octree.intersectMesh(this.ray, this.mesh, this.octree); + return octree.intersectMesh(this._ray, this.mesh, this.octree); } else if (object instanceof Array) { - return this.ray.intersectObjects(object, true); + return this._ray.intersectObjects(object, true); } else { - return this.ray.intersectObject(object, true); + return this._ray.intersectObject(object, true); } - }, + }; - getIntersectsFromEvent: function (event, object) { - return this.getIntersects(event.clientX, event.clientY, object); - }, + _getIntersectsFromEvent = (e, object) => this._getIntersects(e.clientX, e.clientY, object); - worldToScreen: function (vector) { - var widthHalf = this.width() / 2; - var heightHalf = this.height() / 2; - var result = vector.project(this.sCamera); + _worldToScreen = (vector) => { + const widthHalf = this._width() / 2; + const heightHalf = this._height() / 2; + const result = vector.project(this._sCamera); result.x = (result.x * widthHalf) + widthHalf; result.y = -(result.y * heightHalf) + heightHalf; return result; - }, - - localToScreen: function (vector) { - return this.worldToScreen( - this.sMeshAndLms.localToWorld(vector.clone())); - }, - - worldToLocal: function (vector, inPlace=false) { - return inPlace ? this.sMeshAndLms.worldToLocal(vector) : - this.sMeshAndLms.worldToLocal(vector.clone()); - }, - - lmToScreen: function (lmSymbol) { - var pos = lmSymbol.position.clone(); - this.sMeshAndLms.localToWorld(pos); - return this.worldToScreen(pos); - }, - - lmViewsInSelectionBox: function (x1, y1, x2, y2) { - var c; - var lmsInBox = []; - var that = this; - _.each(this.landmarkViews, function (lmView) { - if (lmView.symbol) { - c = that.lmToScreen(lmView.symbol); - if (c.x > x1 && c.x < x2 && c.y > y1 && c.y < y2) { - lmsInBox.push(lmView); - } + }; + + _localToScreen = (vector) => + this._worldToScreen( + this._sMeshAndLms.localToWorld(vector.clone())); + + _worldToLocal = (vector, inPlace=false) => { + return inPlace ? this._sMeshAndLms.worldToLocal(vector) : + this._sMeshAndLms.worldToLocal(vector.clone()); + }; + + _lmToScreen = (lmSymbol) => + this._worldToScreen(this._sMeshAndLms.localToWorld(lmSymbol.position.clone())); + + _lmViewsInSelectionBox = (x1, y1, x2, y2) => + this._landmarkViews.filter(lmv => { + if (lmv.symbol) { + const c = this._lmToScreen(lmv.symbol); + return c.x > x1 && c.x < x2 && c.y > y1 && c.y < y2 + } else { + return false } - }); - return lmsInBox; - }, - - lmViewVisible: function (lmView) { - if (!lmView.symbol) { + _lmViewVisible = (lmv) => { + if (!lmv.symbol) { return false; } - var screenCoords = this.lmToScreen(lmView.symbol); + const screenCoords = this._lmToScreen(lmv.symbol); // intersect the mesh and the landmarks - var iMesh = this.getIntersects( + const iMesh = this._getIntersects( screenCoords.x, screenCoords.y, this.mesh); - var iLm = this.getIntersects( - screenCoords.x, screenCoords.y, lmView.symbol); + const iLm = this._getIntersects( + screenCoords.x, screenCoords.y, lmv.symbol); // is there no mesh here (pretty rare as landmarks have to be on mesh) // or is the mesh behind the landmarks? return iMesh.length === 0 || iMesh[0].distance > iLm[0].distance; - } - -}); + }; +} diff --git a/src/js/app/model/octree.js b/src/js/app/view/viewport/octree.js similarity index 99% rename from src/js/app/model/octree.js rename to src/js/app/view/viewport/octree.js index 3ca30f20..e45f0215 100644 --- a/src/js/app/model/octree.js +++ b/src/js/app/view/viewport/octree.js @@ -1,4 +1,3 @@ - 'use strict'; import THREE from 'three'; diff --git a/src/js/index.js b/src/js/index.js index 140165a6..790ea87f 100644 --- a/src/js/index.js +++ b/src/js/index.js @@ -18,7 +18,7 @@ import SidebarView from './app/view/sidebar'; import HelpOverlay from './app/view/help'; import ToolbarView from './app/view/toolbar'; import URLState from './app/view/url_state'; -import ViewportView from './app/view/viewport'; +import { BackboneViewport } from './app/view/bbviewport'; import KeyboardShortcutsHandler from './app/view/keyboard'; import Config from './app/model/config'; @@ -239,7 +239,8 @@ function initLandmarker(server, mode, u) { new ToolbarView({model: app}); new HelpOverlay({model: app}); - var viewport = new ViewportView({model: app}); + var bbviewport = new BackboneViewport(app); + var viewport = bbviewport.viewport; var prevAsset = null; diff --git a/webpack/webpack.dev.config.js b/webpack/webpack.dev.config.js index 977aed42..b8a4a0a0 100644 --- a/webpack/webpack.dev.config.js +++ b/webpack/webpack.dev.config.js @@ -1,5 +1,6 @@ var webpackConfig = require("./webpack.base.config.js"); -webpackConfig.devtool = "eval-source-map"; +webpackConfig.devtool = "source-map"; webpackConfig.debug = true; +webpackConfig.output.publicPath = '/'; module.exports = webpackConfig;