From 7dc7ab3560bcd2945ddd2e78d17df300b404e66b Mon Sep 17 00:00:00 2001 From: Joseph Coffland Date: Thu, 27 Feb 2020 15:16:15 -0800 Subject: [PATCH] Working on editor, file dialog, modal dialogs. --- CHANGELOG.md | 10 + CODE_TAG | 2 +- jshint.json | 3 +- package.json | 2 +- scripts/11-automount.rules | 10 +- scripts/bbctrl.service | 1 + scripts/eject-usb | 16 + scripts/mount-usb | 44 + setup.py | 2 + src/js/api.js | 40 +- src/js/app.js | 201 +- src/js/axis-vars.js | 4 +- src/js/cm-gcode.js | 64 + src/js/cookie.js | 61 +- src/js/dialog.js | 126 + src/js/file-dialog.js | 102 + src/js/files.js | 289 + src/js/filters.js | 122 + src/js/gcode-viewer.js | 172 - src/js/{io-view.js => loading-message.js} | 12 +- src/js/main.js | 137 +- src/js/message.js | 10 + src/js/modbus-reg.js | 2 +- src/js/nav-item.js | 41 + src/js/nav-menu.js | 40 + src/js/orbit.js | 1472 ++- src/js/path-viewer.js | 466 +- ...dmin-general-view.js => settings-admin.js} | 97 +- src/js/settings-general.js | 34 + src/js/settings-io.js | 34 + src/js/{motor-view.js => settings-motor.js} | 5 +- ...in-network-view.js => settings-network.js} | 35 +- src/js/{tool-view.js => settings-tool.js} | 25 +- src/js/sock.js | 2 +- src/js/util.js | 115 + src/js/video.js | 59 + src/js/{control-view.js => view-control.js} | 163 +- src/js/view-editor.js | 251 + src/js/view-files.js | 99 + src/js/view-settings.js | 108 + src/js/view-viewer.js | 143 + ...settings-view.js => viewer-help-dialog.js} | 15 +- src/pug/index.pug | 100 +- src/pug/templates/dialog.pug | 41 + src/pug/templates/file-dialog.pug | 42 + src/pug/templates/files.pug | 90 + src/pug/templates/loading-message.pug | 35 + src/pug/templates/message.pug | 6 +- .../{modbus-reg-view.pug => modbus-reg.pug} | 2 +- src/pug/templates/path-viewer.pug | 27 +- ...in-general-view.pug => settings-admin.pug} | 77 +- ...settings-view.pug => settings-general.pug} | 6 +- .../{io-view.pug => settings-io.pug} | 2 +- .../{motor-view.pug => settings-motor.pug} | 2 +- ...-network-view.pug => settings-network.pug} | 19 +- .../{tool-view.pug => settings-tool.pug} | 2 +- .../templates/{gcode-viewer.pug => video.pug} | 13 +- src/pug/templates/view-camera.pug | 39 + ...at-sheet-view.pug => view-cheat-sheet.pug} | 6 +- .../{control-view.pug => view-control.pug} | 97 +- src/pug/templates/view-editor.pug | 101 + src/pug/templates/view-files.pug | 66 + .../{help-view.pug => view-help.pug} | 2 +- .../{license-view.pug => view-license.pug} | 4 +- src/pug/templates/view-settings.pug | 72 + src/pug/templates/view-viewer.pug | 94 + src/pug/templates/viewer-help-dialog.pug | 65 + src/py/bbctrl/Camera.py | 4 +- src/py/bbctrl/Config.py | 4 +- src/py/bbctrl/Ctrl.py | 18 +- src/py/bbctrl/{FileHandler.py => Events.py} | 67 +- src/py/bbctrl/FileSystem.py | 190 + src/py/bbctrl/FileSystemHandler.py | 94 + src/py/bbctrl/IOLoop.py | 6 +- src/py/bbctrl/Mach.py | 6 +- src/py/bbctrl/MonitorTemp.py | 1 + src/py/bbctrl/Planner.py | 2 +- src/py/bbctrl/Preplanner.py | 84 +- src/py/bbctrl/RequestHandler.py | 12 +- src/py/bbctrl/State.py | 61 - src/py/bbctrl/Web.py | 61 +- src/py/bbctrl/__init__.py | 4 +- .../images/{isometric.png => angled.png} | Bin src/resources/images/back.png | Bin 0 -> 4163 bytes src/resources/images/bottom.png | Bin 0 -> 4244 bytes src/resources/images/left.png | Bin 0 -> 4385 bytes src/resources/images/right.png | Bin 0 -> 4156 bytes src/static/css/clusterize.css | 38 - src/static/css/codemirror.css | 349 + src/static/js/clusterize.min.js | 17 - src/static/js/codemirror.js | 9807 +++++++++++++++++ src/static/js/three.min.js | 1902 ++-- src/static/js/ui.js | 35 - src/stylus/attention.styl | 8 + src/stylus/cheat-sheet.styl | 34 + src/stylus/code-mirror.styl | 24 + src/stylus/console.styl | 27 + src/stylus/error-message.styl | 18 + src/stylus/estop.styl | 19 + src/stylus/files.styl | 115 + src/stylus/header.styl | 62 + src/stylus/indicators.styl | 48 + src/stylus/io.styl | 10 + src/stylus/loading-message.styl | 27 + src/stylus/log.styl | 8 + src/stylus/main.styl | 31 + src/stylus/menu.styl | 20 + src/stylus/modal.styl | 62 + src/stylus/modbus.styl | 35 + src/stylus/motor-slave.styl | 11 + src/stylus/navbar.styl | 63 + src/stylus/overlay.styl | 17 + src/stylus/path-viewer.styl | 45 + src/stylus/save.styl | 7 + src/stylus/status-colors.styl | 32 + src/stylus/status.styl | 16 + src/stylus/style.styl | 991 +- src/stylus/tabs.styl | 48 + src/stylus/upgrade-version.styl | 11 + src/stylus/video.styl | 40 + src/stylus/view-camera.styl | 14 + src/stylus/view-control.styl | 277 + src/stylus/view-editor.styl | 11 + src/stylus/view-files.styl | 7 + src/stylus/view-help.styl | 3 + src/stylus/view-settings.styl | 12 + src/stylus/view-viewer.styl | 51 + src/stylus/wifi.styl | 6 + 128 files changed, 16886 insertions(+), 3732 deletions(-) create mode 100755 scripts/eject-usb create mode 100755 scripts/mount-usb create mode 100644 src/js/cm-gcode.js create mode 100644 src/js/dialog.js create mode 100644 src/js/file-dialog.js create mode 100644 src/js/files.js create mode 100644 src/js/filters.js delete mode 100644 src/js/gcode-viewer.js rename src/js/{io-view.js => loading-message.js} (87%) create mode 100644 src/js/nav-item.js create mode 100644 src/js/nav-menu.js rename src/js/{admin-general-view.js => settings-admin.js} (61%) create mode 100644 src/js/settings-general.js create mode 100644 src/js/settings-io.js rename src/js/{motor-view.js => settings-motor.js} (98%) rename src/js/{admin-network-view.js => settings-network.js} (83%) rename src/js/{tool-view.js => settings-tool.js} (88%) create mode 100644 src/js/util.js create mode 100644 src/js/video.js rename src/js/{control-view.js => view-control.js} (71%) create mode 100644 src/js/view-editor.js create mode 100644 src/js/view-files.js create mode 100644 src/js/view-settings.js create mode 100644 src/js/view-viewer.js rename src/js/{settings-view.js => viewer-help-dialog.js} (88%) create mode 100644 src/pug/templates/dialog.pug create mode 100644 src/pug/templates/file-dialog.pug create mode 100644 src/pug/templates/files.pug create mode 100644 src/pug/templates/loading-message.pug rename src/pug/templates/{modbus-reg-view.pug => modbus-reg.pug} (98%) rename src/pug/templates/{admin-general-view.pug => settings-admin.pug} (67%) rename src/pug/templates/{settings-view.pug => settings-general.pug} (98%) rename src/pug/templates/{io-view.pug => settings-io.pug} (98%) rename src/pug/templates/{motor-view.pug => settings-motor.pug} (98%) rename src/pug/templates/{admin-network-view.pug => settings-network.pug} (93%) rename src/pug/templates/{tool-view.pug => settings-tool.pug} (99%) rename src/pug/templates/{gcode-viewer.pug => video.pug} (88%) create mode 100644 src/pug/templates/view-camera.pug rename src/pug/templates/{cheat-sheet-view.pug => view-cheat-sheet.pug} (99%) rename src/pug/templates/{control-view.pug => view-control.pug} (79%) create mode 100644 src/pug/templates/view-editor.pug create mode 100644 src/pug/templates/view-files.pug rename src/pug/templates/{help-view.pug => view-help.pug} (98%) rename src/pug/templates/{license-view.pug => view-license.pug} (96%) create mode 100644 src/pug/templates/view-settings.pug create mode 100644 src/pug/templates/view-viewer.pug create mode 100644 src/pug/templates/viewer-help-dialog.pug rename src/py/bbctrl/{FileHandler.py => Events.py} (55%) create mode 100644 src/py/bbctrl/FileSystem.py create mode 100644 src/py/bbctrl/FileSystemHandler.py rename src/resources/images/{isometric.png => angled.png} (100%) create mode 100644 src/resources/images/back.png create mode 100644 src/resources/images/bottom.png create mode 100644 src/resources/images/left.png create mode 100644 src/resources/images/right.png delete mode 100644 src/static/css/clusterize.css create mode 100644 src/static/css/codemirror.css delete mode 100644 src/static/js/clusterize.min.js create mode 100644 src/static/js/codemirror.js delete mode 100644 src/static/js/ui.js create mode 100644 src/stylus/attention.styl create mode 100644 src/stylus/cheat-sheet.styl create mode 100644 src/stylus/code-mirror.styl create mode 100644 src/stylus/console.styl create mode 100644 src/stylus/error-message.styl create mode 100644 src/stylus/estop.styl create mode 100644 src/stylus/files.styl create mode 100644 src/stylus/header.styl create mode 100644 src/stylus/indicators.styl create mode 100644 src/stylus/io.styl create mode 100644 src/stylus/loading-message.styl create mode 100644 src/stylus/log.styl create mode 100644 src/stylus/main.styl create mode 100644 src/stylus/menu.styl create mode 100644 src/stylus/modal.styl create mode 100644 src/stylus/modbus.styl create mode 100644 src/stylus/motor-slave.styl create mode 100644 src/stylus/navbar.styl create mode 100644 src/stylus/overlay.styl create mode 100644 src/stylus/path-viewer.styl create mode 100644 src/stylus/save.styl create mode 100644 src/stylus/status-colors.styl create mode 100644 src/stylus/status.styl create mode 100644 src/stylus/tabs.styl create mode 100644 src/stylus/upgrade-version.styl create mode 100644 src/stylus/video.styl create mode 100644 src/stylus/view-camera.styl create mode 100644 src/stylus/view-control.styl create mode 100644 src/stylus/view-editor.styl create mode 100644 src/stylus/view-files.styl create mode 100644 src/stylus/view-help.styl create mode 100644 src/stylus/view-settings.styl create mode 100644 src/stylus/view-viewer.styl create mode 100644 src/stylus/wifi.styl diff --git a/CHANGELOG.md b/CHANGELOG.md index 0daeee9..e08ffdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,16 @@ Buildbotics CNC Controller Firmware Changelog ============================================= +## v1.0.0 + - Added online GCode editor. + - Added online file dialog. + - Allow subdirectories of files. + - Full page 3D viewer. + - Full page camera view. + - Move all configuration pages to ``SETTINGS``. + - Moved ``Save`` button to ``SETTINGS`` pages. + - Added firmware check message. + ## v0.4.16 - Improved axis under/over warning tooltip. - Added support for DMM DYN4 VFD. diff --git a/CODE_TAG b/CODE_TAG index 218f12e..1263b81 100644 --- a/CODE_TAG +++ b/CODE_TAG @@ -1,6 +1,6 @@ This file is part of the Buildbotics firmware. -Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. +Copyright (c) 2015 - 2021, Buildbotics LLC, All rights reserved. This Source describes Open Hardware and is licensed under the CERN-OHL-S v2. diff --git a/jshint.json b/jshint.json index bb0cbb3..9329841 100644 --- a/jshint.json +++ b/jshint.json @@ -10,6 +10,7 @@ "Vue": false, "SockJS": false, "Gauge": false, - "Clusterize": false + "Clusterize": false, + "CodeMirror": false } } diff --git a/package.json b/package.json index 77a85b2..38fb94e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bbctrl", - "version": "0.4.16", + "version": "1.0.0", "homepage": "http://buildbotics.com/", "repository": "https://github.com/buildbotics/bbctrl-firmware", "license": "CERN-OHL-S v2", diff --git a/scripts/11-automount.rules b/scripts/11-automount.rules index 96967d4..3b149b7 100644 --- a/scripts/11-automount.rules +++ b/scripts/11-automount.rules @@ -1,10 +1,4 @@ KERNEL!="sd[a-z]*", GOTO="automount_end" -IMPORT{program}="/sbin/blkid -o udev -p %N" -ENV{ID_FS_TYPE}=="", GOTO="automount_end" -ENV{ID_FS_LABEL}!="", ENV{dir_name}="%E{ID_FS_LABEL}" -ENV{ID_FS_LABEL}=="", ENV{dir_name}="usb-%k" -ACTION=="add", ENV{mount_options}="relatime" -ACTION=="add", ENV{ID_FS_TYPE}=="vfat|ntfs", ENV{mount_options}="$env{mount_options},utf8,gid=100,umask=002,sync" -ACTION=="add", RUN+="/bin/mkdir -p /media/%E{dir_name}", RUN+="/bin/mount -o $env{mount_options} /dev/%k /media/%E{dir_name}" -ACTION=="remove", ENV{dir_name}!="", RUN+="/bin/umount -l /media/%E{dir_name}", RUN+="/bin/rmdir /media/%E{dir_name}" +ACTION=="add", RUN+="/usr/local/bin/mount-usb %k" +ACTION=="remove", RUN+="/usr/local/bin/mount-usb %k -u" LABEL="automount_end" diff --git a/scripts/bbctrl.service b/scripts/bbctrl.service index 8b22cc7..0f6a067 100644 --- a/scripts/bbctrl.service +++ b/scripts/bbctrl.service @@ -10,6 +10,7 @@ Restart=always StandardOutput=null Nice=-10 KillMode=process +TimeoutStopSec=10 [Install] WantedBy=multi-user.target diff --git a/scripts/eject-usb b/scripts/eject-usb new file mode 100755 index 0000000..146cc03 --- /dev/null +++ b/scripts/eject-usb @@ -0,0 +1,16 @@ +#!/bin/bash + +if [ "$1" == "" ]; then + echo "Usage: $0 " + exit 1 +fi + +if [ ! -d "$1" ]; then + echo "Mount point '$1' not found" + exit 1 +fi + +DEV=$(findmnt -n -o SOURCE --target "$1" | sed 's/^\/dev\/\([^0-9]*\).*$/\1/') + +echo offline > /sys/block/$DEV/device/state +echo 1 > /sys/block/$DEV/device/delete diff --git a/scripts/mount-usb b/scripts/mount-usb new file mode 100755 index 0000000..78e5c75 --- /dev/null +++ b/scripts/mount-usb @@ -0,0 +1,44 @@ +#!/bin/bash + +if [ "$1" == "" ]; then + echo "Usage: $0 [-u]" + exit 1 +fi + +DEV=/dev/$1 + +eval $(/sbin/blkid -o udev -p $DEV) + +if [ "$ID_FS_USAGE" != "filesystem" ]; then + echo "$DEV not a filesystem" + exit 1 +fi + +MOUNT_POINT=$ID_FS_LABEL +if [ "$MOUNT_POINT" == "" ]; then + MOUNT_POINT=USB_DISK-$1 +fi +MOUNT_POINT=/media/"$MOUNT_POINT" + +OPTS=relatime,noauto #,users + +if [ "$ID_FS_TYPE" == "vfat" -o "$ID_FS_TYPE" == "ntfs" ]; then + OPTS+=",utf8,gid=100,umask=002,sync" +fi + +if [ "$2" == "-u" ]; then + /bin/umount $DEV + /bin/sed -i "/^\/dev\/$1/d" /etc/fstab + rmdir "$MOUNT_POINT" + +else + /bin/sed -i "/^\/dev\/$1/d" /etc/fstab + /bin/mkdir -p "$MOUNT_POINT" + + MOUNT_POINT=$(echo -n "$MOUNT_POINT" | /bin/sed 's/ /\\040/g') + echo "$DEV $MOUNT_POINT auto $OPTS 0 0" >> /etc/fstab + + /bin/mount $DEV +fi + +curl -X PUT 127.0.0.1:80/api/usb/update diff --git a/setup.py b/setup.py index efe8c53..38c1a9b 100755 --- a/setup.py +++ b/setup.py @@ -34,6 +34,8 @@ setup( 'scripts/edit-config', 'scripts/edit-boot-config', 'scripts/browser', + 'scripts/mount-usb', + 'scripts/eject-usb', ], install_requires = 'tornado sockjs-tornado pyserial pyudev smbus2'.split(), zip_safe = False, diff --git a/src/js/api.js b/src/js/api.js index 4808512..7425417 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -32,7 +32,7 @@ function api_cb(method, url, data, config) { config = $.extend({ type: method, url: '/api/' + url, - dataType: 'json', + dataType: 'text', cache: false }, config); @@ -44,7 +44,14 @@ function api_cb(method, url, data, config) { var d = $.Deferred(); $.ajax(config).success(function (data, status, xhr) { - d.resolve(data, status, xhr); + try { + if (data) data = JSON.parse(data); + + d.resolve(data, status, xhr); + + } catch (e) { + d.reject(data, xhr, status, 'Failed to parse JSON'); + } }).error(function (xhr, status, error) { var text = xhr.responseText; @@ -87,18 +94,27 @@ module.exports = { }, - 'delete': function (url, config) { - return api_cb('DELETE', url, undefined, config); - }, - + download: function(url, type) { + var d = $.Deferred(); + var xhr = new XMLHttpRequest(); - alert: function (msg, error) { - if (typeof error != 'undefined') { - if (typeof error.message != 'undefined') - msg += '\n' + error.message; - else msg += '\n' + JSON.stringify(error); + xhr.open('GET', '/api/' + url + '?' + Math.random(), true); + xhr.responseType = type || 'text'; + xhr.onload = function () { + if (200 <= xhr.status && xhr.status < 300) + d.resolve(xhr.response, xhr.status, xhr) + else d.reject('', xhr, xhr.status, xhr.statusText) } + xhr.onerror = function () { + d.reject('', xhr, xhr.status, xhr.statusText) + } + xhr.send(); + + return d.promise(); + }, - alert(msg); + + 'delete': function (url, config) { + return api_cb('DELETE', url, undefined, config); } } diff --git a/src/js/app.js b/src/js/app.js index 45133bf..9512d29 100644 --- a/src/js/app.js +++ b/src/js/app.js @@ -27,9 +27,9 @@ 'use strict' -var api = require('./api'); -var cookie = require('./cookie')('bbctrl-'); -var Sock = require('./sock'); +var api = require('./api'); +var cookie = require('./cookie'); +var Sock = require('./sock'); function compare_versions(a, b) { @@ -95,8 +95,6 @@ module.exports = new Vue({ return { status: 'connecting', currentView: 'loading', - index: -1, - modified: false, template: require('../resources/config-template.json'), config: { settings: {units: 'METRIC'}, @@ -104,45 +102,42 @@ module.exports = new Vue({ version: '' }, state: {messages: []}, - video_size: cookie.get('video-size', 'small'), - crosshair: cookie.get('crosshair', 'false') != 'false', + crosshair: cookie.get_bool('crosshair', false), errorTimeout: 30, errorTimeoutStart: 0, - errorShow: false, errorMessage: '', - confirmUpgrade: false, - confirmUpload: false, - firmwareUpgrading: false, checkedUpgrade: false, - firmwareName: '', latestVersion: '', - password: '' + showError: false } }, components: { - 'estop': {template: '#estop-template'}, - 'loading-view': {template: '

Loading...

'}, - 'control-view': require('./control-view'), - 'settings-view': require('./settings-view'), - 'motor-view': require('./motor-view'), - 'chart-view': require('./chart-view'), - 'tool-view': require('./tool-view'), - 'io-view': require('./io-view'), - 'admin-general-view': require('./admin-general-view'), - 'admin-network-view': require('./admin-network-view'), - 'help-view': {template: '#help-view-template'}, - 'license-view': {template: '#license-view-template'}, - 'cheat-sheet-view': { - template: '#cheat-sheet-view-template', + 'estop': {template: '#estop-template'}, + 'view-not-found': {template: '

Error: View not found

'}, + 'view-loading': {template: '

Loading...

'}, + 'view-control': require('./view-control'), + 'view-viewer': require('./view-viewer'), + 'view-editor': require('./view-editor'), + 'view-settings': require('./view-settings'), + 'view-files': require('./view-files'), + 'view-camera': {template: '#view-camera-template'}, + 'view-help': {template: '#view-help-template'}, + 'view-license': {template: '#view-license-template'}, + 'view-cheat-sheet': { + template: '#view-cheat-sheet-template', data: function () {return {showUnimplemented: false}} } }, + watch: { + crosshair: function () {cookie.set_bool('crosshair', this.crosshair)} + }, + + events: { - 'config-changed': function () {this.modified = true;}, 'hostname-changed': function (hostname) {this.hostname = hostname}, @@ -158,7 +153,7 @@ module.exports = new Vue({ update: function () {this.update()}, - check: function () { + check: function (show_message) { this.latestVersion = ''; $.ajax({ @@ -169,22 +164,26 @@ module.exports = new Vue({ }).done(function (data) { this.latestVersion = data; - this.$broadcast('latest_version', data); - }.bind(this)) - }, + if (!show_message) return; + var cmp = compare_versions(this.config.version, this.latestVersion); + var msg; + if (cmp == 0) msg = 'You have the latest official firmware.' + else { + msg = 'Your firmware is ' + (cmp < 0 ? 'older': 'newer') + + ' than the latest official firmware release, version' + + this.latestVersion + '.' - upgrade: function () { - this.password = ''; - this.confirmUpgrade = true; - }, + if (cmp < 0) msg += ' Please upgrade.'; + } + this.open_dialog({ + icon: cmp ? (cmp < 0 ? 'chevron-left' : 'chevron-right') : 'check', + title: 'Firmware check', + body: msg + }) - upload: function (firmware) { - this.firmware = firmware; - this.firmwareName = firmware.name; - this.password = ''; - this.confirmUpload = true; + }.bind(this)) }, @@ -197,7 +196,7 @@ module.exports = new Vue({ if (1 < msg.repeat && Date.now() - msg.ts < 1000) return; // Popup error dialog - this.errorShow = true; + this.showError = true; this.errorMessage = msg.msg; } }, @@ -213,6 +212,12 @@ module.exports = new Vue({ } return msgs; + }, + + + show_upgrade: function () { + if (!this.latestVersion) return false; + return compare_versions(this.config.version, this.latestVersion) < 0; } }, @@ -229,21 +234,7 @@ module.exports = new Vue({ block_error_dialog: function () { this.errorTimeoutStart = Date.now(); - this.errorShow = false; - }, - - - toggle_video: function (e) { - if (this.video_size == 'small') this.video_size = 'large'; - else if (this.video_size == 'large') this.video_size = 'small'; - cookie.set('video-size', this.video_size); - }, - - - toggle_crosshair: function (e) { - e.preventDefault(); - this.crosshair = !this.crosshair; - cookie.set('crosshair', this.crosshair); + this.showError = false; }, @@ -253,48 +244,6 @@ module.exports = new Vue({ }, - upgrade_confirmed: function () { - this.confirmUpgrade = false; - - api.put('upgrade', {password: this.password}).done(function () { - this.firmwareUpgrading = true; - - }.bind(this)).fail(function () { - api.alert('Invalid password'); - }.bind(this)) - }, - - - upload_confirmed: function () { - this.confirmUpload = false; - - var form = new FormData(); - form.append('firmware', this.firmware); - if (this.password) form.append('password', this.password); - - $.ajax({ - url: '/api/firmware/update', - type: 'PUT', - data: form, - cache: false, - contentType: false, - processData: false - - }).success(function () { - this.firmwareUpgrading = true; - - }.bind(this)).error(function () { - api.alert('Invalid password or bad firmware'); - }.bind(this)) - }, - - - show_upgrade: function () { - if (!this.latestVersion) return false; - return compare_versions(this.config.version, this.latestVersion) < 0; - }, - - update: function () { api.get('config/load').done(function (config) { update_object(this.config, config, true); @@ -363,18 +312,10 @@ module.exports = new Vue({ var parts = hash.split(':'); - if (parts.length == 2) this.index = parts[1]; - - this.currentView = parts[0]; - }, - + if (typeof this.$options.components['view-' + parts[0]] == 'undefined') + this.currentView = 'not-found'; - save: function () { - api.put('config/save', this.config).done(function (data) { - this.modified = false; - }.bind(this)).fail(function (error) { - api.alert('Save failed', error); - }); + else this.currentView = parts[0]; }, @@ -387,6 +328,46 @@ module.exports = new Vue({ var id = this.state.messages.slice(-1)[0].id api.put('message/' + id + '/ack'); } + }, + + + file_dialog: function (config) {this.$refs.fileDialog.open(config)}, + open_dialog: function (config) {this.$refs.dialog.open(config)}, + error_dialog: function (msg) {this.$refs.dialog.error(msg)}, + warning_dialog: function (msg) {this.$refs.dialog.warning(msg)}, + success_dialog: function (msg) {this.$refs.dialog.success(msg)}, + + + api_error: function (msg, error) { + if (typeof error != 'undefined') { + if (typeof error.message != 'undefined') + msg += '\n' + error.message; + else msg += '\n' + JSON.stringify(error); + } + + this.error_dialog(msg); + }, + + + run: function (path) { + if (this.state.xx != 'READY') return; + + api.put('queue/' + path).done(function () { + location.hash = 'control'; + api.put('start'); + }) + }, + + + edit: function (path) { + cookie.set('selected-path', path); + location.hash = 'editor'; + }, + + + view: function (path) { + cookie.set('selected-path', path); + location.hash = 'viewer'; } } }) diff --git a/src/js/axis-vars.js b/src/js/axis-vars.js index 489d074..17e5a23 100644 --- a/src/js/axis-vars.js +++ b/src/js/axis-vars.js @@ -69,8 +69,8 @@ module.exports = { var min = this.state[motor_id + 'tn']; var max = this.state[motor_id + 'tm']; var dim = max - min; - var pathMin = this.state['path_min_' + axis]; - var pathMax = this.state['path_max_' + axis]; + var pathMin = this.state['queued_min_' + axis]; + var pathMax = this.state['queued_max_' + axis]; var pathDim = pathMax - pathMin; var under = pathMin + off < min; var over = max < pathMax + off; diff --git a/src/js/cm-gcode.js b/src/js/cm-gcode.js new file mode 100644 index 0000000..3b6ae02 --- /dev/null +++ b/src/js/cm-gcode.js @@ -0,0 +1,64 @@ +/******************************************************************************\ + + This file is part of the Buildbotics firmware. + + Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. + + This Source describes Open Hardware and is licensed under the + CERN-OHL-S v2. + + You may redistribute and modify this Source and make products + using it under the terms of the CERN-OHL-S v2 (https:/cern.ch/cern-ohl). + This Source is distributed WITHOUT ANY EXPRESS OR IMPLIED + WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY AND FITNESS + FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-S v2 for applicable + conditions. + + Source location: https://github.com/buildbotics + + As per CERN-OHL-S v2 section 4, should You produce hardware based on + these sources, You must maintain the Source Location clearly visible on + the external case of the CNC Controller or other product you make using + this Source. + + For more information, email info@buildbotics.com + +\******************************************************************************/ + +'use strict'; + + +CodeMirror.defineMode('gcode', function (config, parserConfig) { + return { + token: function (stream, state) { + if (stream.eatSpace()) return null; + + if (stream.match(';')) { + stream.skipToEnd(); + return 'comment'; + } + + if (stream.match('(')) { + if (stream.skipTo(')')) stream.next(); + else stream.skipToEnd(); + return 'comment'; + } + + if (stream.match(/[+-]?[\d.]+/)) return 'number'; + if (stream.match(/[\/*%=+-]/)) return 'operator'; + if (stream.match('[\[\]]')) return 'bracket'; + if (stream.match(/N\d+/i)) return 'line'; + if (stream.match(/O\d+\s*[a-z]+/i)) return 'ocode'; + if (stream.match(/[F][+-]?[\d.]+/i)) return 'feed'; + if (stream.match(/[S][+-]?[\d.]+/i)) return 'speed'; + if (stream.match(/[T]\d+/i)) return 'tool'; + if (stream.match(/[GM][\d.]+/i)) return 'gcode'; + if (stream.match(/[A-Z]/i)) return 'id'; + if (stream.match(/#<[_a-z\d]+>/i)) return 'variable'; + if (stream.match(/#\d+/)) return 'ref'; + + stream.next(); + return 'error'; + } + } +}) diff --git a/src/js/cookie.js b/src/js/cookie.js index 4ba5c02..75f7c4f 100644 --- a/src/js/cookie.js +++ b/src/js/cookie.js @@ -28,44 +28,45 @@ 'use strict' -module.exports = function (prefix) { - if (typeof prefix == 'undefined') prefix = ''; +var cookie = { + prefix: 'bbctrl-', - var cookie = { - get: function (name, defaultValue) { - var decodedCookie = decodeURIComponent(document.cookie); - var ca = decodedCookie.split(';'); - name = prefix + name + '='; - for (var i = 0; i < ca.length; i++) { - var c = ca[i]; - while (c.charAt(0) == ' ') c = c.substring(1); - if (!c.indexOf(name)) return c.substring(name.length, c.length); - } + get: function (name, defaultValue) { + var decodedCookie = decodeURIComponent(document.cookie); + var ca = decodedCookie.split(';'); + name = cookie.prefix + name + '='; - return defaultValue; - }, + for (var i = 0; i < ca.length; i++) { + var c = ca[i]; + while (c.charAt(0) == ' ') c = c.substring(1); + if (!c.indexOf(name)) return c.substring(name.length, c.length); + } + return defaultValue; + }, - set: function (name, value, days) { - var offset = 2147483647; // Max value - if (typeof days != 'undefined') offset = days * 24 * 60 * 60 * 1000; - var d = new Date(); - d.setTime(d.getTime() + offset); - var expires = 'expires=' + d.toUTCString(); - document.cookie = prefix + name + '=' + value + ';' + expires + ';path=/'; - }, + set: function (name, value, days) { + var offset = 2147483647; // Max value + if (typeof days != 'undefined') offset = days * 24 * 60 * 60 * 1000; + var d = new Date(); + d.setTime(d.getTime() + offset); + var expires = 'expires=' + d.toUTCString(); + document.cookie = + cookie.prefix + name + '=' + value + ';' + expires + ';path=/'; + }, - set_bool: function (name, value) { - cookie.set(name, value ? 'true' : 'false'); - }, + set_bool: function (name, value) { + cookie.set(name, value ? 'true' : 'false'); + }, - get_bool: function (name, defaultValue) { - return cookie.get(name, defaultValue ? 'true' : 'false') == 'true'; - } - } - return cookie; + get_bool: function (name, defaultValue) { + return cookie.get(name, defaultValue ? 'true' : 'false') == 'true'; + } } + + +module.exports = cookie; diff --git a/src/js/dialog.js b/src/js/dialog.js new file mode 100644 index 0000000..dd025ab --- /dev/null +++ b/src/js/dialog.js @@ -0,0 +1,126 @@ +/******************************************************************************\ + + This file is part of the Buildbotics firmware. + + Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. + + This Source describes Open Hardware and is licensed under the + CERN-OHL-S v2. + + You may redistribute and modify this Source and make products + using it under the terms of the CERN-OHL-S v2 (https:/cern.ch/cern-ohl). + This Source is distributed WITHOUT ANY EXPRESS OR IMPLIED + WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY AND FITNESS + FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-S v2 for applicable + conditions. + + Source location: https://github.com/buildbotics + + As per CERN-OHL-S v2 section 4, should You produce hardware based on + these sources, You must maintain the Source Location clearly visible on + the external case of the CNC Controller or other product you make using + this Source. + + For more information, email info@buildbotics.com + +\******************************************************************************/ + +'use strict' + + +function get_icon(action) { + switch (action.toLowerCase()) { + case 'ok': case 'yes': return 'check'; + case 'cancel': case 'no': return 'times'; + } + + return undefined +} + + +module.exports = { + template: '#dialog-template', + + + data: function () { + return { + show: false, + config: {}, + buttons: [] + } + }, + + + methods: { + click_away: function () { + if (typeof this.config.click_away == 'undefined') + this.close('click-away'); + + if (this.config.click_away) this.close(this.config.click_away); + }, + + + close: function (action) { + this.show = false + + if (typeof this.config.callback == 'function') + this.config.callback(action); + + if (typeof this.config.callback == 'object' && + typeof this.config.callback[action] == 'function') + this.config.callback[action](); + }, + + + open: function(config) { + this.config = config; + + var buttons = config.buttons || 'OK'; + if (typeof buttons == 'string') buttons = buttons.split(' '); + + this.buttons = []; + for (var i = 0; i < buttons.length; i++) { + if (typeof buttons[i] == 'string') + this.buttons.push({ + action: buttons[i].toLowerCase(), + text: buttons[i], + icon: get_icon(buttons[i]) + }) + + else { + buttons[i].action = buttons[i].action || buttons[i].text.toLowerCase() + this.buttons.push(buttons[i]); + } + } + + this.show = true; + }, + + + error: function (msg) { + this.open({ + icon: 'exclamation', + title: 'Error', + body: msg + }) + }, + + + warning: function (msg) { + this.open({ + icon: 'exclamation-triangle', + title: 'Warning', + body: msg + }) + }, + + + success: function (msg) { + this.open({ + icon: 'check', + title: 'Success', + body: msg + }) + } + } +} diff --git a/src/js/file-dialog.js b/src/js/file-dialog.js new file mode 100644 index 0000000..3f82134 --- /dev/null +++ b/src/js/file-dialog.js @@ -0,0 +1,102 @@ +/******************************************************************************\ + + This file is part of the Buildbotics firmware. + + Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. + + This Source describes Open Hardware and is licensed under the + CERN-OHL-S v2. + + You may redistribute and modify this Source and make products + using it under the terms of the CERN-OHL-S v2 (https:/cern.ch/cern-ohl). + This Source is distributed WITHOUT ANY EXPRESS OR IMPLIED + WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY AND FITNESS + FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-S v2 for applicable + conditions. + + Source location: https://github.com/buildbotics + + As per CERN-OHL-S v2 section 4, should You produce hardware based on + these sources, You must maintain the Source Location clearly visible on + the external case of the CNC Controller or other product you make using + this Source. + + For more information, email info@buildbotics.com + +\******************************************************************************/ + +'use strict' + + +var util = require('./util'); + + +module.exports = { + template: '#file-dialog-template', + props: ['locations'], + + + data: function () { + return { + show: false, + config: {}, + selected: undefined, + dir: false + } + }, + + + methods: { + open: function (config) { + this.config = config; + this.show = true; + this.$refs.files.open(config.dir || '/'); + }, + + + set_selected: function (path, dir) { + this.selected = path; + this.dir = dir; + }, + + + respond: function (path) { + if (this.config.callback) this.config.callback(path); + }, + + + response: function (path) { + this.show = false; + + if (this.config.save) { + var filename = util.basename(path); + var exists = this.$refs.files.has_file(filename); + + if (exists) { + this.$root.open_dialog({ + title: 'Overwrite file?', + body: 'Overwrite ' + filename + '?', + buttons: 'No Yes', + callback: { + no: this.respond, + yes: function () {this.respond(path)}.bind(this) + } + }) + + return; + } + } + + this.respond(path); + }, + + + ok: function () { + if (this.dir) this.$refs.files.open(this.selected); + else this.response(this.selected) + }, + + + cancel: function () {this.response()} + } +} diff --git a/src/js/files.js b/src/js/files.js new file mode 100644 index 0000000..ba3c987 --- /dev/null +++ b/src/js/files.js @@ -0,0 +1,289 @@ +/******************************************************************************\ + + This file is part of the Buildbotics firmware. + + Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. + + This Source describes Open Hardware and is licensed under the + CERN-OHL-S v2. + + You may redistribute and modify this Source and make products + using it under the terms of the CERN-OHL-S v2 (https:/cern.ch/cern-ohl). + This Source is distributed WITHOUT ANY EXPRESS OR IMPLIED + WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY AND FITNESS + FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-S v2 for applicable + conditions. + + Source location: https://github.com/buildbotics + + As per CERN-OHL-S v2 section 4, should You produce hardware based on + these sources, You must maintain the Source Location clearly visible on + the external case of the CNC Controller or other product you make using + this Source. + + For more information, email info@buildbotics.com + +\******************************************************************************/ + +'use strict' + +var api = require('./api') +var util = require('./util') + + +function order_files(a, b) { + if (a.dir != b.dir) return a.dir ? -1 : 1; + return a.name.localeCompare(b.name); +} + + +function valid_filename(name) { + return name.length && name[0] != '.' && name.indexOf('/') == -1; +} + + +module.exports = { + template: '#files-template', + props: ['mode', 'locations'], + + + data: function () { + return { + fs: {}, + selected: -1, + filename: '', + folder: '', + activeFile: {}, + showNewFolder: false + } + }, + + + watch: { + selected: function () { + var path; + var dir = false; + + if (0 <= this.selected && this.selected <= this.files.length) { + var file = this.files[this.selected]; + if (file.dir) dir = true; + path = this.file_path(file); + } + + if (this.mode != 'save') this.$emit('selected', path, dir); + if (path && !dir) this.filename = util.basename(path); + }, + + + filename: function () { + if (this.mode != 'save') return; + var path; + if (this.filename_valid) + path = util.join_path(this.fs.path, this.filename) + this.$emit('selected', path, false); + }, + + + locations: function () { + if (this.locations.indexOf(this.location) == -1) + this.load('') + } + }, + + + computed: { + files: function () { + if (typeof this.fs.files == 'undefined') return []; + return this.fs.files.sort(order_files); + }, + + + location: function () { + if (typeof this.fs.path != 'undefined') { + var paths = this.fs.path.split('/').filter(function (s) {return s}); + if (paths.length) return paths[0]; + } + + return 'Home'; + }, + + + paths: function () { + if (typeof this.fs.path == 'undefined') return []; + + var paths = this.fs.path.split('/').filter(function (s) {return s}); + if (paths.length) paths.shift(); // Remove location + paths.unshift('/'); + + return paths; + }, + + + folder_valid: function () { + var file = this.find_file(this.folder); + return file == undefined && valid_filename(this.folder); + }, + + + filename_valid: function () { + var file = this.find_file(this.filename); + return (file == undefined || !file.dir) && valid_filename(this.filename); + } + }, + + + ready: function () {this.load('')}, + + + methods: { + location_title: function (name) { + if (name == 'Home') + return 'Select files already on the controller.'; + return 'Select files from a USB drive.'; + }, + + + filename_changed: function () { + if (this.selected != -1 && + this.filename != this.files[this.selected].name) + this.selected = -1; + }, + + + find_file: function (name) { + for (var i = 0; i < this.files.length; i++) + if (this.files[i].name == name) return this.files[i]; + return undefined; + }, + + + has_file: function (name) {return this.find_file(name) != undefined}, + file_path: function (file) {return util.join_path(this.fs.path, file.name)}, + file_url: function (file) {return '/api/fs' + this.file_path(file)}, + select: function (index) {this.selected = index}, + + + eject: function (location) { + api.put('usb/eject/' + location) + }, + + + open: function (path) { + this.filename = ''; + this.load(path); + }, + + + load: function (path) { + api.get('fs/' + path) + .done(function (data) { + this.fs = data + this.selected = -1; + }.bind(this)) + }, + + + reload: function () {this.load(this.fs.path || '')}, + + + path_at: function (index) { + return '/' + this.paths.slice(1, index + 1).join('/'); + }, + + + path_title: function (index) { + if (index == this.paths.length - 1) return ''; + return 'Go to folder ' + this.path_at(index); + }, + + + load_path: function (index) { + this.load(this.location + this.path_at(index)) + }, + + + new_folder: function () { + this.folder = ''; + this.showNewFolder = true; + }, + + + create_folder: function () { + if (!this.folder_valid) return; + this.showNewFolder = false; + + api.put('fs/' + this.fs.path + '/' + this.folder) + .done(this.reload); + }, + + + activate: function (file) { + if (file.dir) this.load(this.fs.path + '/' + file.name); + else this.$emit('activate', this.file_path(file)); + }, + + + delete: function (file) { + this.$root.open_dialog({ + title: 'Delete ' + (file.dir ? 'directory' : 'file') + '?', + body: 'Are you sure you want to delete ' + file.name + + (file.dir ? ' and all the files under it?' : '?'), + buttons: 'Cancel OK', + callback: function (action) { + if (action == 'ok') + api.delete('fs/' + this.fs.path + '/' + file.name) + .done(this.reload) + }.bind(this) + }); + }, + + + upload: function () { + // If we don't reset the form the browser may cache file if name is same + // even if contents have changed + this.$els.uploadForm.reset(); + this.$els.uploadFormInput.click(); + }, + + + do_upload: function (e) { + var files = e.target.files || e.dataTransfer.files; + if (!files.length) return; + + var file = files[0]; + var filename = util.basename(util.unix_path(file.name)); + + var upload = function() { + var fd = new FormData(); + fd.append('file', file); + + api.upload('fs/' + this.fs.path + '/' + filename, fd) + .done(this.reload) + .fail(function (error) { + this.$root.api_error('Upload failed', error) + }.bind(this)); + }.bind(this); + + // Check if file already exists + var other = this.find_file(filename); + + if (other) { + if (other.dir) + this.$root.open_dialog({ + title: 'Cannot overwrite', + body: 'Cannot overwrite directory ' + filename + '.', + buttons: 'OK' + }); + + else + this.$root.open_dialog({ + title: 'Overwrite file?', + body: 'Are you sure you want to overwrite ' + filename + '?', + buttons: 'Cancel OK', + callback: function (action) {if (action == 'ok') upload()} + }); + + } else upload(); + } + } +} diff --git a/src/js/filters.js b/src/js/filters.js new file mode 100644 index 0000000..3ebd9b6 --- /dev/null +++ b/src/js/filters.js @@ -0,0 +1,122 @@ +/******************************************************************************\ + + This file is part of the Buildbotics firmware. + + Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. + + This Source describes Open Hardware and is licensed under the + CERN-OHL-S v2. + + You may redistribute and modify this Source and make products + using it under the terms of the CERN-OHL-S v2 (https:/cern.ch/cern-ohl). + This Source is distributed WITHOUT ANY EXPRESS OR IMPLIED + WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY AND FITNESS + FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-S v2 for applicable + conditions. + + Source location: https://github.com/buildbotics + + As per CERN-OHL-S v2 section 4, should You produce hardware based on + these sources, You must maintain the Source Location clearly visible on + the external case of the CNC Controller or other product you make using + this Source. + + For more information, email info@buildbotics.com + +\******************************************************************************/ + +'use strict'; + +var util = require('./util'); + + +var filters = { + number: function (value) { + if (isNaN(value)) return 'NaN'; + return value.toLocaleString(); + }, + + + percent: function (value, precision) { + if (typeof value == 'undefined') return ''; + if (typeof precision == 'undefined') precision = 2; + return (value * 100.0).toFixed(precision) + '%'; + }, + + + + non_zero_percent: function (value, precision) { + if (!value) return ''; + if (typeof precision == 'undefined') precision = 2; + return (value * 100.0).toFixed(precision) + '%'; + }, + + + fixed: function (value, precision) { + if (typeof value == 'undefined') return '0'; + return parseFloat(value).toFixed(precision) + }, + + + upper: function (value) { + if (typeof value == 'undefined') return ''; + return value.toUpperCase() + }, + + + time: function (value, precision) { + if (isNaN(value)) return ''; + if (isNaN(precision)) precision = 0; + + var MIN = 60; + var HR = MIN * 60; + var DAY = HR * 24; + var parts = []; + + if (DAY <= value) { + parts.push(Math.floor(value / DAY)); + value %= DAY; + } + + if (HR <= value) { + parts.push(Math.floor(value / HR)); + value %= HR; + } + + if (MIN <= value) { + parts.push(Math.floor(value / MIN)); + value %= MIN; + + } else parts.push(0); + + parts.push(value); + + for (var i = 0; i < parts.length; i++) { + parts[i] = parts[i].toFixed(i == parts.length - 1 ? precision : 0); + if (i && parts[i] < 10) parts[i] = '0' + parts[i]; + } + + return parts.join(':'); + }, + + + ago: function (ts) { + if (typeof ts == 'string') ts = Date.parse(ts) / 1000; + + return util.human_duration(new Date().getTime() / 1000 - ts) + ' ago'; + }, + + + duration: function (ts, precision) { + return util.human_duration(parseInt(ts), precision) + }, + + + size: function (x, precision) {return util.human_size(x, precision)} +} + + +module.exports = function () { + for (var name in filters) + Vue.filter(name, filters[name]) +} diff --git a/src/js/gcode-viewer.js b/src/js/gcode-viewer.js deleted file mode 100644 index 52d6aae..0000000 --- a/src/js/gcode-viewer.js +++ /dev/null @@ -1,172 +0,0 @@ -/******************************************************************************\ - - This file is part of the Buildbotics firmware. - - Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. - - This Source describes Open Hardware and is licensed under the - CERN-OHL-S v2. - - You may redistribute and modify this Source and make products - using it under the terms of the CERN-OHL-S v2 (https:/cern.ch/cern-ohl). - This Source is distributed WITHOUT ANY EXPRESS OR IMPLIED - WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY AND FITNESS - FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-S v2 for applicable - conditions. - - Source location: https://github.com/buildbotics - - As per CERN-OHL-S v2 section 4, should You produce hardware based on - these sources, You must maintain the Source Location clearly visible on - the external case of the CNC Controller or other product you make using - this Source. - - For more information, email info@buildbotics.com - -\******************************************************************************/ - -'use strict' - -var api = require('./api'); - - -var entityMap = { - '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', - '/': '/', '`': '`', '=': '='} - - -function escapeHTML(s) { - return s.replace(/[&<>"'`=\/]/g, function (c) {return entityMap[c]}) -} - - -module.exports = { - template: '#gcode-viewer-template', - - - data: function () { - return { - empty: true, - file: '', - line: -1, - scrolling: false - } - }, - - - events: { - 'gcode-load': function (file) {this.load(file)}, - 'gcode-clear': function () {this.clear()}, - 'gcode-reload': function (file) {this.reload(file)}, - 'gcode-line': function (line) {this.update_line(line)} - }, - - - ready: function () { - this.clusterize = new Clusterize({ - rows: [], - scrollElem: $(this.$el).find('.clusterize-scroll')[0], - contentElem: $(this.$el).find('.clusterize-content')[0], - no_data_text: 'GCode view...', - callbacks: {clusterChanged: this.highlight} - }); - }, - - - attached: function () { - if (typeof this.clusterize != 'undefined') - this.clusterize.refresh(true); - }, - - - methods: { - load: function (file) { - if (file == this.file) return; - this.clear(); - this.file = file; - - if (!file) return; - - var xhr = new XMLHttpRequest(); - xhr.open('GET', '/api/file/' + file + '?' + Math.random(), true); - xhr.responseType = 'text'; - - xhr.onload = function (e) { - if (this.file != file) return; - var lines = escapeHTML(xhr.response.trimRight()).split(/\r?\n/); - - for (var i = 0; i < lines.length; i++) { - lines[i] = '
  • ' + - '' + (i + 1) + '' + lines[i] + '
  • '; - } - - this.clusterize.update(lines); - this.empty = false; - - Vue.nextTick(this.update_line); - }.bind(this) - - xhr.send(); - }, - - - clear: function () { - this.empty = true; - this.file = ''; - this.line = -1; - this.clusterize.clear(); - }, - - - reload: function (file) { - if (file != this.file) return; - this.clear(); - this.load(file); - }, - - - highlight: function () { - var e = $(this.$el).find('.highlight'); - if (e.length) e.removeClass('highlight'); - - e = $(this.$el).find('.ln' + this.line); - if (e.length) e.addClass('highlight'); - }, - - - update_line: function(line) { - if (typeof line != 'undefined') { - if (this.line == line) return; - this.line = line; - - } else line = this.line; - - var totalLines = this.clusterize.getRowsAmount(); - - if (line <= 0) line = 1; - if (totalLines < line) line = totalLines; - - var e = $(this.$el).find('.clusterize-scroll'); - - var lineHeight = e[0].scrollHeight / totalLines; - var linesPerPage = Math.floor(e[0].clientHeight / lineHeight); - var current = e[0].scrollTop / lineHeight; - var target = line - 1 - Math.floor(linesPerPage / 2); - - // Update scroll position - if (!this.scrolling) { - if (target < current - 20 || current + 20 < target) - e[0].scrollTop = target * lineHeight; - - else { - this.scrolling = true; - e.animate({scrollTop: target * lineHeight}, { - complete: function () {this.scrolling = false}.bind(this) - }) - } - } - - Vue.nextTick(this.highlight); - } - } -} diff --git a/src/js/io-view.js b/src/js/loading-message.js similarity index 87% rename from src/js/io-view.js rename to src/js/loading-message.js index 344f177..b253eeb 100644 --- a/src/js/io-view.js +++ b/src/js/loading-message.js @@ -29,14 +29,6 @@ module.exports = { - template: '#io-view-template', - props: ['config', 'template', 'state'], - - - events: { - 'input-changed': function() { - this.$dispatch('config-changed'); - return false; - } - } + template: '#loading-message-template', + props: ['progress'] } diff --git a/src/js/main.js b/src/js/main.js index 9b88e31..5a25ab4 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -28,119 +28,58 @@ 'use strict'; -function cookie_get(name) { - var decodedCookie = decodeURIComponent(document.cookie); - var ca = decodedCookie.split(';'); - name = name + '='; - - for (var i = 0; i < ca.length; i++) { - var c = ca[i]; - while (c.charAt(0) == ' ') c = c.substring(1); - if (!c.indexOf(name)) return c.substring(name.length, c.length); - } -} - - -function cookie_set(name, value, days) { - var d = new Date(); - d.setTime(d.getTime() + days * 24 * 60 * 60 * 1000); - var expires = 'expires=' + d.toUTCString(); - document.cookie = name + '=' + value + ';' + expires + ';path=/'; -} - +var cookie = require('./cookie'); +var util = require('./util'); -var uuid_chars = - 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_+'; +function ui() { + var layout = document.getElementById('layout'); + var menu = document.getElementById('menu'); + var menuLink = document.getElementById('menuLink'); -function uuid(length) { - if (typeof length == 'undefined') length = 52; - - var s = ''; - for (var i = 0; i < length; i++) - s += uuid_chars[Math.floor(Math.random() * uuid_chars.length)]; + menuLink.onclick = function (e) { + e.preventDefault(); + layout.classList.toggle('active'); + menu.classList.toggle('active'); + menuLink.classList.toggle('active'); + } - return s + menu.onclick = function (e) { + layout.classList.remove('active'); + menu.classList.remove('active'); + menuLink.classList.remove('active'); + } } $(function() { - if (typeof cookie_get('client-id') == 'undefined') - cookie_set('client-id', uuid(), 10000); + ui(); + + if (typeof cookie.get('client-id') == 'undefined') + cookie.set('client-id', util.uuid()); // Vue debugging Vue.config.debug = true; - //Vue.util.warn = function (msg) {console.debug('[Vue warn]: ' + msg)} + + // CodeMirror GCode mode + require('./cm-gcode'); // Register global components Vue.component('templated-input', require('./templated-input')); - Vue.component('message', require('./message')); - Vue.component('indicators', require('./indicators')); - Vue.component('io-indicator', require('./io-indicator')); - Vue.component('console', require('./console')); - Vue.component('unit-value', require('./unit-value')); - - Vue.filter('number', function (value) { - if (isNaN(value)) return 'NaN'; - return value.toLocaleString(); - }); - - Vue.filter('percent', function (value, precision) { - if (typeof value == 'undefined') return ''; - if (typeof precision == 'undefined') precision = 2; - return (value * 100.0).toFixed(precision) + '%'; - }); - - Vue.filter('non_zero_percent', function (value, precision) { - if (!value) return ''; - if (typeof precision == 'undefined') precision = 2; - return (value * 100.0).toFixed(precision) + '%'; - }); - - Vue.filter('fixed', function (value, precision) { - if (typeof value == 'undefined') return '0'; - return parseFloat(value).toFixed(precision) - }); - - Vue.filter('upper', function (value) { - if (typeof value == 'undefined') return ''; - return value.toUpperCase() - }); - - Vue.filter('time', function (value, precision) { - if (isNaN(value)) return ''; - if (isNaN(precision)) precision = 0; - - var MIN = 60; - var HR = MIN * 60; - var DAY = HR * 24; - var parts = []; - - if (DAY <= value) { - parts.push(Math.floor(value / DAY)); - value %= DAY; - } - - if (HR <= value) { - parts.push(Math.floor(value / HR)); - value %= HR; - } - - if (MIN <= value) { - parts.push(Math.floor(value / MIN)); - value %= MIN; - - } else parts.push(0); - - parts.push(value); - - for (var i = 0; i < parts.length; i++) { - parts[i] = parts[i].toFixed(i == parts.length - 1 ? precision : 0); - if (i && parts[i] < 10) parts[i] = '0' + parts[i]; - } - - return parts.join(':'); - }); + Vue.component('message', require('./message')); + Vue.component('loading-message', require('./loading-message')); + Vue.component('dialog', require('./dialog')); + Vue.component('indicators', require('./indicators')); + Vue.component('io-indicator', require('./io-indicator')); + Vue.component('console', require('./console')); + Vue.component('unit-value', require('./unit-value')); + Vue.component('files', require('./files')); + Vue.component('file-dialog', require('./file-dialog')); + Vue.component('nav-menu', require('./nav-menu')); + Vue.component('nav-item', require('./nav-item')); + Vue.component('video', require('./video')); + + require('./filters')(); // Vue app require('./app'); diff --git a/src/js/message.js b/src/js/message.js index 23a6c75..33d7a42 100644 --- a/src/js/message.js +++ b/src/js/message.js @@ -36,6 +36,16 @@ module.exports = { type: Boolean, required: true, twoWay: true + }, + + click_away_close: { + type: Boolean, + default: true } + }, + + + events: { + 'click-away': function () {if (this.click_away_close) this.show = false} } } diff --git a/src/js/modbus-reg.js b/src/js/modbus-reg.js index 45799bf..547ca70 100644 --- a/src/js/modbus-reg.js +++ b/src/js/modbus-reg.js @@ -30,7 +30,7 @@ module.exports = { replace: true, - template: '#modbus-reg-view-template', + template: '#modbus-reg-template', props: ['index', 'model', 'template', 'enable'], diff --git a/src/js/nav-item.js b/src/js/nav-item.js new file mode 100644 index 0000000..c0489f7 --- /dev/null +++ b/src/js/nav-item.js @@ -0,0 +1,41 @@ +/******************************************************************************\ + + This file is part of the Buildbotics firmware. + + Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. + + This Source describes Open Hardware and is licensed under the + CERN-OHL-S v2. + + You may redistribute and modify this Source and make products + using it under the terms of the CERN-OHL-S v2 (https:/cern.ch/cern-ohl). + This Source is distributed WITHOUT ANY EXPRESS OR IMPLIED + WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY AND FITNESS + FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-S v2 for applicable + conditions. + + Source location: https://github.com/buildbotics + + As per CERN-OHL-S v2 section 4, should You produce hardware based on + these sources, You must maintain the Source Location clearly visible on + the external case of the CNC Controller or other product you make using + this Source. + + For more information, email info@buildbotics.com + +\******************************************************************************/ + +'use strict'; + + +module.exports = { + template: '', + + + methods: { + show: function (e) { + $(e.currentTarget).find('.nav-menu-hide').removeClass('nav-menu-hide'); + } + } +} diff --git a/src/js/nav-menu.js b/src/js/nav-menu.js new file mode 100644 index 0000000..1c959bb --- /dev/null +++ b/src/js/nav-menu.js @@ -0,0 +1,40 @@ +/******************************************************************************\ + + This file is part of the Buildbotics firmware. + + Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. + + This Source describes Open Hardware and is licensed under the + CERN-OHL-S v2. + + You may redistribute and modify this Source and make products + using it under the terms of the CERN-OHL-S v2 (https:/cern.ch/cern-ohl). + This Source is distributed WITHOUT ANY EXPRESS OR IMPLIED + WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY AND FITNESS + FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-S v2 for applicable + conditions. + + Source location: https://github.com/buildbotics + + As per CERN-OHL-S v2 section 4, should You produce hardware based on + these sources, You must maintain the Source Location clearly visible on + the external case of the CNC Controller or other product you make using + this Source. + + For more information, email info@buildbotics.com + +\******************************************************************************/ + +'use strict'; + + +module.exports = { + template: '', + + + methods: { + hide: function (e) { + e.currentTarget.classList.add('nav-menu-hide') + } + } +} diff --git a/src/js/orbit.js b/src/js/orbit.js index eb62316..a95bcaf 100644 --- a/src/js/orbit.js +++ b/src/js/orbit.js @@ -31,679 +31,1177 @@ * @author alteredq / http://alteredqualia.com/ * @author WestLangley / http://github.com/WestLangley * @author erich666 / http://erichaines.com - * @author jcoffland / https://buildbotics.com/ + * @author ScieCode / http://github.com/sciecode */ -'use strict' - // This set of controls performs orbiting, dollying (zooming), and panning. -// Unlike TrackballControls, it maintains the "up" direction object.up -// (+Y by default). +// Unlike TrackballControls, it maintains the "up" direction object.up (+Y by default). // // Orbit - left mouse / touch: one-finger move // Zoom - middle mouse, or mousewheel / touch: two-finger spread or squish -// Pan - right mouse, or arrow keys / touch: two-finger move +// Pan - right mouse, or left mouse + ctrl/meta/shiftKey, or arrow keys / touch: two-finger move + +THREE.OrbitControls = function ( object, domElement ) { + + if ( domElement === undefined ) console.warn( 'THREE.OrbitControls: The second parameter "domElement" is now mandatory.' ); + if ( domElement === document ) console.error( 'THREE.OrbitControls: "document" should not be used as the target "domElement". Please use "renderer.domElement" instead.' ); + + this.object = object; + this.domElement = domElement; + + // Set to false to disable this control + this.enabled = true; + + // "target" sets the location of focus, where the object orbits around + this.target = new THREE.Vector3(); + + // How far you can dolly in and out ( PerspectiveCamera only ) + this.minDistance = 0; + this.maxDistance = Infinity; + + // How far you can zoom in and out ( OrthographicCamera only ) + this.minZoom = 0; + this.maxZoom = Infinity; + + // How far you can orbit vertically, upper and lower limits. + // Range is 0 to Math.PI radians. + this.minPolarAngle = 0; // radians + this.maxPolarAngle = Math.PI; // radians + + // How far you can orbit horizontally, upper and lower limits. + // If set, must be a sub-interval of the interval [ - Math.PI, Math.PI ]. + this.minAzimuthAngle = - Infinity; // radians + this.maxAzimuthAngle = Infinity; // radians + + // Set to true to enable damping (inertia) + // If damping is enabled, you must call controls.update() in your animation loop + this.enableDamping = false; + this.dampingFactor = 0.05; + + // This option actually enables dollying in and out; left as "zoom" for backwards compatibility. + // Set to false to disable zooming + this.enableZoom = true; + this.zoomSpeed = 1.0; + + // Set to false to disable rotating + this.enableRotate = true; + this.rotateSpeed = 1.0; + + // Set to false to disable panning + this.enablePan = true; + this.panSpeed = 1.0; + this.screenSpacePanning = false; // if true, pan in screen-space + this.keyPanSpeed = 7.0; // pixels moved per arrow key push + + // Set to true to automatically rotate around the target + // If auto-rotate is enabled, you must call controls.update() in your animation loop + this.autoRotate = false; + this.autoRotateSpeed = 2.0; // 30 seconds per round when fps is 60 + + // Set to false to disable use of the keys + this.enableKeys = true; + + // The four arrow keys + this.keys = { LEFT: 37, UP: 38, RIGHT: 39, BOTTOM: 40 }; + + // Mouse buttons + this.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.PAN }; + + // Touch fingers + this.touches = { ONE: THREE.TOUCH.ROTATE, TWO: THREE.TOUCH.DOLLY_PAN }; + + // for reset + this.target0 = this.target.clone(); + this.position0 = this.object.position.clone(); + this.zoom0 = this.object.zoom; + + // + // public methods + // + + this.getPolarAngle = function () { + + return spherical.phi; + + }; + + this.getAzimuthalAngle = function () { + + return spherical.theta; + + }; + + this.saveState = function () { + + scope.target0.copy( scope.target ); + scope.position0.copy( scope.object.position ); + scope.zoom0 = scope.object.zoom; + + }; + + this.reset = function () { + + scope.target.copy( scope.target0 ); + scope.object.position.copy( scope.position0 ); + scope.object.zoom = scope.zoom0; + + scope.object.updateProjectionMatrix(); + scope.dispatchEvent( changeEvent ); + + scope.update(); + + state = STATE.NONE; + + }; + + // this method is exposed, but perhaps it would be better if we can make it private... + this.update = function () { + + var offset = new THREE.Vector3(); + + // so camera.up is the orbit axis + var quat = new THREE.Quaternion().setFromUnitVectors( object.up, new THREE.Vector3( 0, 1, 0 ) ); + var quatInverse = quat.clone().inverse(); + + var lastPosition = new THREE.Vector3(); + var lastQuaternion = new THREE.Quaternion(); + + return function update() { + + var position = scope.object.position; + + offset.copy( position ).sub( scope.target ); + + // rotate offset to "y-axis-is-up" space + offset.applyQuaternion( quat ); + + // angle from z-axis around y-axis + spherical.setFromVector3( offset ); + + if ( scope.autoRotate && state === STATE.NONE ) { + + rotateLeft( getAutoRotationAngle() ); + + } + + if ( scope.enableDamping ) { + + spherical.theta += sphericalDelta.theta * scope.dampingFactor; + spherical.phi += sphericalDelta.phi * scope.dampingFactor; + + } else { + + spherical.theta += sphericalDelta.theta; + spherical.phi += sphericalDelta.phi; + + } + + // restrict theta to be between desired limits + spherical.theta = Math.max( scope.minAzimuthAngle, Math.min( scope.maxAzimuthAngle, spherical.theta ) ); + + // restrict phi to be between desired limits + spherical.phi = Math.max( scope.minPolarAngle, Math.min( scope.maxPolarAngle, spherical.phi ) ); + + spherical.makeSafe(); + + + spherical.radius *= scale; + + // restrict radius to be between desired limits + spherical.radius = Math.max( scope.minDistance, Math.min( scope.maxDistance, spherical.radius ) ); + + // move target to panned location + + if ( scope.enableDamping === true ) { + + scope.target.addScaledVector( panOffset, scope.dampingFactor ); + + } else { + + scope.target.add( panOffset ); + + } + + offset.setFromSpherical( spherical ); + + // rotate offset back to "camera-up-vector-is-up" space + offset.applyQuaternion( quatInverse ); + + position.copy( scope.target ).add( offset ); + + scope.object.lookAt( scope.target ); + + if ( scope.enableDamping === true ) { + + sphericalDelta.theta *= ( 1 - scope.dampingFactor ); + sphericalDelta.phi *= ( 1 - scope.dampingFactor ); + + panOffset.multiplyScalar( 1 - scope.dampingFactor ); + + } else { + + sphericalDelta.set( 0, 0, 0 ); + + panOffset.set( 0, 0, 0 ); + + } + + scale = 1; + + // update condition is: + // min(camera displacement, camera rotation in radians)^2 > EPS + // using small-angle approximation cos(x/2) = 1 - x^2 / 8 + + if ( zoomChanged || + lastPosition.distanceToSquared( scope.object.position ) > EPS || + 8 * ( 1 - lastQuaternion.dot( scope.object.quaternion ) ) > EPS ) { + + scope.dispatchEvent( changeEvent ); + + lastPosition.copy( scope.object.position ); + lastQuaternion.copy( scope.object.quaternion ); + zoomChanged = false; + + return true; + + } + + return false; + + }; + + }(); + + this.dispose = function () { + + scope.domElement.removeEventListener( 'contextmenu', onContextMenu, false ); + scope.domElement.removeEventListener( 'mousedown', onMouseDown, false ); + scope.domElement.removeEventListener( 'wheel', onMouseWheel, false ); + + scope.domElement.removeEventListener( 'touchstart', onTouchStart, false ); + scope.domElement.removeEventListener( 'touchend', onTouchEnd, false ); + scope.domElement.removeEventListener( 'touchmove', onTouchMove, false ); + + document.removeEventListener( 'mousemove', onMouseMove, false ); + document.removeEventListener( 'mouseup', onMouseUp, false ); + + scope.domElement.removeEventListener( 'keydown', onKeyDown, false ); + + //scope.dispatchEvent( { type: 'dispose' } ); // should this be added here? + + }; + + // + // internals + // + + var scope = this; + + var changeEvent = { type: 'change' }; + var startEvent = { type: 'start' }; + var endEvent = { type: 'end' }; + + var STATE = { + NONE: - 1, + ROTATE: 0, + DOLLY: 1, + PAN: 2, + TOUCH_ROTATE: 3, + TOUCH_PAN: 4, + TOUCH_DOLLY_PAN: 5, + TOUCH_DOLLY_ROTATE: 6 + }; + + var state = STATE.NONE; + + var EPS = 0.000001; + + // current position in spherical coordinates + var spherical = new THREE.Spherical(); + var sphericalDelta = new THREE.Spherical(); + + var scale = 1; + var panOffset = new THREE.Vector3(); + var zoomChanged = false; + + var rotateStart = new THREE.Vector2(); + var rotateEnd = new THREE.Vector2(); + var rotateDelta = new THREE.Vector2(); + + var panStart = new THREE.Vector2(); + var panEnd = new THREE.Vector2(); + var panDelta = new THREE.Vector2(); + + var dollyStart = new THREE.Vector2(); + var dollyEnd = new THREE.Vector2(); + var dollyDelta = new THREE.Vector2(); + + function getAutoRotationAngle() { + + return 2 * Math.PI / 60 / 60 * scope.autoRotateSpeed; + + } + + function getZoomScale() { + + return Math.pow( 0.95, scope.zoomSpeed ); + + } + + function rotateLeft( angle ) { + + sphericalDelta.theta -= angle; + + } + + function rotateUp( angle ) { + + sphericalDelta.phi -= angle; + + } + + var panLeft = function () { + + var v = new THREE.Vector3(); + + return function panLeft( distance, objectMatrix ) { + + v.setFromMatrixColumn( objectMatrix, 0 ); // get X column of objectMatrix + v.multiplyScalar( - distance ); + + panOffset.add( v ); + + }; + + }(); + + var panUp = function () { + + var v = new THREE.Vector3(); + + return function panUp( distance, objectMatrix ) { + + if ( scope.screenSpacePanning === true ) { + + v.setFromMatrixColumn( objectMatrix, 1 ); + + } else { + + v.setFromMatrixColumn( objectMatrix, 0 ); + v.crossVectors( scope.object.up, v ); + + } + + v.multiplyScalar( distance ); + + panOffset.add( v ); + + }; + + }(); + + // deltaX and deltaY are in pixels; right and down are positive + var pan = function () { + + var offset = new THREE.Vector3(); + + return function pan( deltaX, deltaY ) { + + var element = scope.domElement; + + if ( scope.object.isPerspectiveCamera ) { + + // perspective + var position = scope.object.position; + offset.copy( position ).sub( scope.target ); + var targetDistance = offset.length(); + + // half of the fov is center to top of screen + targetDistance *= Math.tan( ( scope.object.fov / 2 ) * Math.PI / 180.0 ); + + // we use only clientHeight here so aspect ratio does not distort speed + panLeft( 2 * deltaX * targetDistance / element.clientHeight, scope.object.matrix ); + panUp( 2 * deltaY * targetDistance / element.clientHeight, scope.object.matrix ); + + } else if ( scope.object.isOrthographicCamera ) { + + // orthographic + panLeft( deltaX * ( scope.object.right - scope.object.left ) / scope.object.zoom / element.clientWidth, scope.object.matrix ); + panUp( deltaY * ( scope.object.top - scope.object.bottom ) / scope.object.zoom / element.clientHeight, scope.object.matrix ); + + } else { + + // camera neither orthographic nor perspective + console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - pan disabled.' ); + scope.enablePan = false; + + } + + }; + + }(); + + function dollyIn( dollyScale ) { + + if ( scope.object.isPerspectiveCamera ) { + + scale /= dollyScale; + + } else if ( scope.object.isOrthographicCamera ) { + + scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom * dollyScale ) ); + scope.object.updateProjectionMatrix(); + zoomChanged = true; + + } else { + + console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' ); + scope.enableZoom = false; + + } + + } + + function dollyOut( dollyScale ) { + + if ( scope.object.isPerspectiveCamera ) { + + scale *= dollyScale; + + } else if ( scope.object.isOrthographicCamera ) { + + scope.object.zoom = Math.max( scope.minZoom, Math.min( scope.maxZoom, scope.object.zoom / dollyScale ) ); + scope.object.updateProjectionMatrix(); + zoomChanged = true; + + } else { + + console.warn( 'WARNING: OrbitControls.js encountered an unknown camera type - dolly/zoom disabled.' ); + scope.enableZoom = false; + } -var OrbitControls = function (object, domElement) { - this.object = object; - this.domElement = domElement != undefined ? domElement : document; + } - // Set to false to disable this control - this.enabled = true; + // + // event callbacks - update the object state + // - // "target" sets the location of focus, where the object orbits around - this.target = new THREE.Vector3(); + function handleMouseDownRotate( event ) { - // How far you can zoom in and out (OrthographicCamera only) - this.minZoom = 0; - this.maxZoom = Infinity; + rotateStart.set( event.clientX, event.clientY ); - // How far you can orbit vertically, upper and lower limits. - // Range is 0 to Math.PI radians. - this.minPolarAngle = 0; // radians - this.maxPolarAngle = Math.PI; // radians + } - // How far you can orbit horizontally, upper and lower limits. - // If set, must be a sub-interval of the interval [- Math.PI, Math.PI]. - this.minAzimuthAngle = -Infinity; // radians - this.maxAzimuthAngle = Infinity; // radians + function handleMouseDownDolly( event ) { - // Set to true to enable damping (inertia) - // If damping is enabled, call controls.update() in your animation loop - this.enableDamping = false; - this.dampingFactor = 0.25; + dollyStart.set( event.clientX, event.clientY ); - // This option enables dollying in and out; - // left as "zoom" for backwards compatibility. - // Set to false to disable zooming - this.enableZoom = true; - this.zoomSpeed = 1.0; + } - // Set to false to disable rotating - this.enableRotate = true; - this.rotateSpeed = 1.0; + function handleMouseDownPan( event ) { - // Set to false to disable panning - this.enablePan = true; - this.panSpeed = 1.0; - this.screenSpacePanning = false; // if true, pan in screen-space - this.keyPanSpeed = 7.0; // pixels moved per arrow key push + panStart.set( event.clientX, event.clientY ); - // Set to true to automatically rotate around the target - // If auto-rotate is enabled, call controls.update() in your animation loop - this.autoRotate = false; - this.autoRotateSpeed = 2.0; // 30 seconds per round when fps is 60 + } - // Set to false to disable use of the keys - this.enableKeys = true; + function handleMouseMoveRotate( event ) { - // The four arrow keys - this.keys = {LEFT: 37, UP: 38, RIGHT: 39, BOTTOM: 40}; + rotateEnd.set( event.clientX, event.clientY ); - // Mouse buttons - this.mouseButtons = { - ORBIT: THREE.MOUSE.LEFT, ZOOM: THREE.MOUSE.MIDDLE, PAN: THREE.MOUSE.RIGHT - }; + rotateDelta.subVectors( rotateEnd, rotateStart ).multiplyScalar( scope.rotateSpeed ); - // for reset - this.target0 = this.target.clone(); - this.position0 = this.object.position.clone(); - this.zoom0 = this.object.zoom; + var element = scope.domElement; - // public methods - this.getPolarAngle = function () {return spherical.phi} - this.getAzimuthalAngle = function () {return spherical.theta} + rotateLeft( 2 * Math.PI * rotateDelta.x / element.clientHeight ); // yes, height + rotateUp( 2 * Math.PI * rotateDelta.y / element.clientHeight ); - this.saveState = function () { - scope.target0.copy(scope.target); - scope.position0.copy(scope.object.position); - scope.zoom0 = scope.object.zoom; - } + rotateStart.copy( rotateEnd ); + scope.update(); - this.reset = function () { - scope.target.copy(scope.target0); - scope.object.position.copy(scope.position0); - scope.object.zoom = scope.zoom0; - scope.object.updateProjectionMatrix(); + } - scope.dispatchEvent(changeEvent); - scope.update(); + function handleMouseMoveDolly( event ) { - state = STATE.NONE; - } + dollyEnd.set( event.clientX, event.clientY ); + dollyDelta.subVectors( dollyEnd, dollyStart ); - this.update = function () { - var offset = new THREE.Vector3(); + if ( dollyDelta.y > 0 ) { - // so camera.up is the orbit axis - var quat = new THREE.Quaternion() - .setFromUnitVectors(object.up, new THREE.Vector3(0, 1, 0)); - var quatInverse = quat.clone().inverse(); + dollyIn( getZoomScale() ); - var lastPosition = new THREE.Vector3(); - var lastQuaternion = new THREE.Quaternion(); + } else if ( dollyDelta.y < 0 ) { - return function update() { - var position = scope.object.position; + dollyOut( getZoomScale() ); - offset.copy(position).sub(scope.target); + } - // rotate offset to "y-axis-is-up" space - offset.applyQuaternion(quat); + dollyStart.copy( dollyEnd ); - // angle from z-axis around y-axis - spherical.setFromVector3(offset); + scope.update(); - if (scope.autoRotate && state == STATE.NONE) - rotateLeft(getAutoRotationAngle()); + } - spherical.theta += sphericalDelta.theta; - spherical.phi += sphericalDelta.phi; + function handleMouseMovePan( event ) { - // restrict theta to be between desired limits - spherical.theta = - Math.max(scope.minAzimuthAngle, - Math.min(scope.maxAzimuthAngle, spherical.theta)); + panEnd.set( event.clientX, event.clientY ); - // restrict phi to be between desired limits - spherical.phi = - Math.max(scope.minPolarAngle, - Math.min(scope.maxPolarAngle, spherical.phi)); + panDelta.subVectors( panEnd, panStart ).multiplyScalar( scope.panSpeed ); - spherical.makeSafe(); - spherical.radius *= scale; + pan( panDelta.x, panDelta.y ); - // restrict radius to be between desired limits - spherical.radius = - Math.max(10, Math.min(scope.object.far * 0.8, spherical.radius)); + panStart.copy( panEnd ); - // move target to panned location - scope.target.add(panOffset); + scope.update(); - offset.setFromSpherical(spherical); + } - // rotate offset back to "camera-up-vector-is-up" space - offset.applyQuaternion(quatInverse); + function handleMouseUp( /*event*/ ) { - position.copy(scope.target).add(offset); - scope.object.lookAt(scope.target); + // no-op - if (scope.enableDamping) { - sphericalDelta.theta *= (1 - scope.dampingFactor); - sphericalDelta.phi *= (1 - scope.dampingFactor); - panOffset.multiplyScalar(1 - scope.dampingFactor); + } - } else { - sphericalDelta.set(0, 0, 0); - panOffset.set(0, 0, 0); - } + function handleMouseWheel( event ) { - // update condition is: - // min(camera displacement, camera rotation in radians)^2 > EPS - // using small-angle approximation cos(x/2) = 1 - x^2 / 8 - if (zoomChanged || scale != 1 || - lastPosition.distanceToSquared(scope.object.position) > EPS || - 8 * (1 - lastQuaternion.dot(scope.object.quaternion)) > EPS) { + if ( event.deltaY < 0 ) { - scope.dispatchEvent(changeEvent); + dollyOut( getZoomScale() ); - lastPosition.copy(scope.object.position); - lastQuaternion.copy(scope.object.quaternion); - zoomChanged = false; - scale = 1; + } else if ( event.deltaY > 0 ) { - return true; - } + dollyIn( getZoomScale() ); - return false; - } - }() + } + scope.update(); - this.dispose = function () { - scope.domElement.removeEventListener('contextmenu', onContextMenu, false); - scope.domElement.removeEventListener('mousedown', onMouseDown, false); - scope.domElement.removeEventListener('wheel', onMouseWheel, false); - scope.domElement.removeEventListener('touchstart', onTouchStart, false); - scope.domElement.removeEventListener('touchend', onTouchEnd, false); - scope.domElement.removeEventListener('touchmove', onTouchMove, false); - document.removeEventListener('mousemove', onMouseMove, false); - document.removeEventListener('mouseup', onMouseUp, false); - window.removeEventListener('keydown', onKeyDown, false); - } + } + function handleKeyDown( event ) { - // internals - var scope = this; + var needsUpdate = false; - var changeEvent = {type: 'change'}; - var startEvent = {type: 'start'}; - var endEvent = {type: 'end'}; + switch ( event.keyCode ) { - var STATE = { - NONE: -1, ROTATE: 0, DOLLY: 1, PAN: 2, TOUCH_ROTATE: 3, TOUCH_DOLLY_PAN: 4 - }; + case scope.keys.UP: + pan( 0, scope.keyPanSpeed ); + needsUpdate = true; + break; - var state = STATE.NONE; - var EPS = 0.000001; + case scope.keys.BOTTOM: + pan( 0, - scope.keyPanSpeed ); + needsUpdate = true; + break; - // current position in spherical coordinates - var spherical = new THREE.Spherical(); - var sphericalDelta = new THREE.Spherical(); + case scope.keys.LEFT: + pan( scope.keyPanSpeed, 0 ); + needsUpdate = true; + break; - var scale = 1; - var panOffset = new THREE.Vector3(); - var zoomChanged = false; + case scope.keys.RIGHT: + pan( - scope.keyPanSpeed, 0 ); + needsUpdate = true; + break; - var rotateStart = new THREE.Vector2(); - var rotateEnd = new THREE.Vector2(); - var rotateDelta = new THREE.Vector2(); + } - var panStart = new THREE.Vector2(); - var panEnd = new THREE.Vector2(); - var panDelta = new THREE.Vector2(); + if ( needsUpdate ) { - var dollyStart = new THREE.Vector2(); - var dollyEnd = new THREE.Vector2(); - var dollyDelta = new THREE.Vector2(); + // prevent the browser from scrolling on cursor keys + event.preventDefault(); + scope.update(); - function getAutoRotationAngle() { - return 2 * Math.PI / 60 / 60 * scope.autoRotateSpeed; - } + } - function getZoomScale() {return Math.pow(0.95, scope.zoomSpeed)} - function rotateLeft(angle) {sphericalDelta.theta -= angle} - function rotateUp(angle) {sphericalDelta.phi -= angle} + } + function handleTouchStartRotate( event ) { - var panLeft = function () { - var v = new THREE.Vector3(); + if ( event.touches.length == 1 ) { - return function panLeft(distance, objectMatrix) { - v.setFromMatrixColumn(objectMatrix, 0); // get X column of objectMatrix - v.multiplyScalar(-distance); - panOffset.add(v); - } - }() + rotateStart.set( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY ); + } else { - var panUp = function () { - var v = new THREE.Vector3(); + var x = 0.5 * ( event.touches[ 0 ].pageX + event.touches[ 1 ].pageX ); + var y = 0.5 * ( event.touches[ 0 ].pageY + event.touches[ 1 ].pageY ); - return function panUp(distance, objectMatrix) { - if (scope.screenSpacePanning) v.setFromMatrixColumn(objectMatrix, 1); - else { - v.setFromMatrixColumn(objectMatrix, 0); - v.crossVectors(scope.object.up, v); - } + rotateStart.set( x, y ); - v.multiplyScalar(distance); - panOffset.add(v); - } - }() + } + } - function unknownCamera() { - console.warn('WARNING: OrbitControls.js encountered an unknown camera ' + - 'type - pan & zoom disabled.'); - scope.enablePan = false; - scope.enableZoom = false; - } + function handleTouchStartPan( event ) { + if ( event.touches.length == 1 ) { - // deltaX and deltaY are in pixels; right and down are positive - var pan = function () { - var offset = new THREE.Vector3(); + panStart.set( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY ); - return function pan(deltaX, deltaY) { - var element = scope.domElement === document ? - scope.domElement.body : scope.domElement; + } else { - if (scope.object.isPerspectiveCamera) { - // perspective - offset.copy(scope.object.position).sub(scope.target); - var targetDistance = offset.length(); + var x = 0.5 * ( event.touches[ 0 ].pageX + event.touches[ 1 ].pageX ); + var y = 0.5 * ( event.touches[ 0 ].pageY + event.touches[ 1 ].pageY ); - // half of the fov is center to top of screen - targetDistance *= Math.tan((scope.object.fov / 2) * Math.PI / 180.0); + panStart.set( x, y ); - // we use only clientHeight here so aspect ratio does not distort speed - panLeft(2 * deltaX * targetDistance / element.clientHeight, - scope.object.matrix); - panUp(2 * deltaY * targetDistance / element.clientHeight, - scope.object.matrix); + } - } else if (scope.object.isOrthographicCamera) { - // orthographic - panLeft(deltaX * (scope.object.right - scope.object.left) / - scope.object.zoom / element.clientWidth, scope.object.matrix); - panUp(deltaY * (scope.object.top - scope.object.bottom) / - scope.object.zoom / element.clientHeight, scope.object.matrix); + } - } else unknownCamera(); - } - }() + function handleTouchStartDolly( event ) { + var dx = event.touches[ 0 ].pageX - event.touches[ 1 ].pageX; + var dy = event.touches[ 0 ].pageY - event.touches[ 1 ].pageY; - function dollyIn(dollyScale) { - if (scope.object.isPerspectiveCamera) scale /= dollyScale; + var distance = Math.sqrt( dx * dx + dy * dy ); - else if (scope.object.isOrthographicCamera) { - scope.object.zoom = - Math.max(scope.minZoom, - Math.min(scope.maxZoom, scope.object.zoom * dollyScale)); - scope.object.updateProjectionMatrix(); - zoomChanged = true; + dollyStart.set( 0, distance ); - } else unknownCamera(); - } + } + function handleTouchStartDollyPan( event ) { - function dollyOut(dollyScale) { - if (scope.object.isPerspectiveCamera) scale *= dollyScale; + if ( scope.enableZoom ) handleTouchStartDolly( event ); - else if (scope.object.isOrthographicCamera) { - scope.object.zoom = - Math.max(scope.minZoom, - Math.min(scope.maxZoom, scope.object.zoom / dollyScale)); - scope.object.updateProjectionMatrix(); - zoomChanged = true; + if ( scope.enablePan ) handleTouchStartPan( event ); - } else unknownCamera(); - } + } + function handleTouchStartDollyRotate( event ) { - // event callbacks - update the object state - function handleMouseDownRotate(event) { - rotateStart.set(event.clientX, event.clientY); - } + if ( scope.enableZoom ) handleTouchStartDolly( event ); + if ( scope.enableRotate ) handleTouchStartRotate( event ); - function handleMouseDownDolly(event) { - dollyStart.set(event.clientX, event.clientY); - } + } + function handleTouchMoveRotate( event ) { - function handleMouseDownPan(event) { - panStart.set(event.clientX, event.clientY); - } + if ( event.touches.length == 1 ) { + rotateEnd.set( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY ); - function handleMouseMoveRotate(event) { - rotateEnd.set(event.clientX, event.clientY); - rotateDelta.subVectors(rotateEnd, rotateStart) - .multiplyScalar(scope.rotateSpeed); + } else { - var element = scope.domElement === document ? - scope.domElement.body : scope.domElement; + var x = 0.5 * ( event.touches[ 0 ].pageX + event.touches[ 1 ].pageX ); + var y = 0.5 * ( event.touches[ 0 ].pageY + event.touches[ 1 ].pageY ); - // yes, height - rotateLeft(2 * Math.PI * rotateDelta.x / element.clientHeight); - rotateUp(2 * Math.PI * rotateDelta.y / element.clientHeight); + rotateEnd.set( x, y ); - rotateStart.copy(rotateEnd); + } - scope.update(); - } + rotateDelta.subVectors( rotateEnd, rotateStart ).multiplyScalar( scope.rotateSpeed ); + var element = scope.domElement; - function handleMouseMoveDolly(event) { - dollyEnd.set(event.clientX, event.clientY); - dollyDelta.subVectors(dollyEnd, dollyStart); + rotateLeft( 2 * Math.PI * rotateDelta.x / element.clientHeight ); // yes, height - if (dollyDelta.y > 0) dollyIn(getZoomScale()); - else if (dollyDelta.y < 0) dollyOut(getZoomScale()); + rotateUp( 2 * Math.PI * rotateDelta.y / element.clientHeight ); - dollyStart.copy(dollyEnd); - scope.update(); - } + rotateStart.copy( rotateEnd ); + } - function handleMouseMovePan(event) { - panEnd.set(event.clientX, event.clientY); - panDelta.subVectors(panEnd, panStart).multiplyScalar(scope.panSpeed); - pan(panDelta.x, panDelta.y); - panStart.copy(panEnd); - scope.update(); - } + function handleTouchMovePan( event ) { + if ( event.touches.length == 1 ) { - function handleMouseUp(event) {} + panEnd.set( event.touches[ 0 ].pageX, event.touches[ 0 ].pageY ); + } else { - function handleMouseWheel(event) { - if (event.deltaY < 0) dollyOut(getZoomScale()); - else if (event.deltaY > 0) dollyIn(getZoomScale()); + var x = 0.5 * ( event.touches[ 0 ].pageX + event.touches[ 1 ].pageX ); + var y = 0.5 * ( event.touches[ 0 ].pageY + event.touches[ 1 ].pageY ); - scope.update(); - } + panEnd.set( x, y ); + } - function handleKeyDown(event) { - switch (event.keyCode) { - case scope.keys.UP: - pan(0, scope.keyPanSpeed); - scope.update(); - break; + panDelta.subVectors( panEnd, panStart ).multiplyScalar( scope.panSpeed ); - case scope.keys.BOTTOM: - pan(0, -scope.keyPanSpeed); - scope.update(); - break; + pan( panDelta.x, panDelta.y ); - case scope.keys.LEFT: - pan(scope.keyPanSpeed, 0); - scope.update(); - break; + panStart.copy( panEnd ); - case scope.keys.RIGHT: - pan(-scope.keyPanSpeed, 0); - scope.update(); - break; - } - } + } + function handleTouchMoveDolly( event ) { - function handleTouchStartRotate(event) { - rotateStart.set(event.touches[0].pageX, event.touches[0].pageY); - } + var dx = event.touches[ 0 ].pageX - event.touches[ 1 ].pageX; + var dy = event.touches[ 0 ].pageY - event.touches[ 1 ].pageY; + var distance = Math.sqrt( dx * dx + dy * dy ); - function handleTouchStartDollyPan(event) { - if (scope.enableZoom) { - var dx = event.touches[0].pageX - event.touches[1].pageX; - var dy = event.touches[0].pageY - event.touches[1].pageY; - var distance = Math.sqrt(dx * dx + dy * dy); + dollyEnd.set( 0, distance ); - dollyStart.set(0, distance); - } + dollyDelta.set( 0, Math.pow( dollyEnd.y / dollyStart.y, scope.zoomSpeed ) ); - if (scope.enablePan) { - var x = 0.5 * (event.touches[0].pageX + event.touches[1].pageX); - var y = 0.5 * (event.touches[0].pageY + event.touches[1].pageY); - panStart.set(x, y); - } - } + dollyIn( dollyDelta.y ); + dollyStart.copy( dollyEnd ); - function handleTouchMoveRotate(event) { - rotateEnd.set(event.touches[0].pageX, event.touches[0].pageY); - rotateDelta.subVectors(rotateEnd, rotateStart) - .multiplyScalar(scope.rotateSpeed); + } - var element = scope.domElement === document ? - scope.domElement.body : scope.domElement; + function handleTouchMoveDollyPan( event ) { - // yes, height - rotateLeft(2 * Math.PI * rotateDelta.x / element.clientHeight); - rotateUp(2 * Math.PI * rotateDelta.y / element.clientHeight); - rotateStart.copy(rotateEnd); - scope.update(); - } + if ( scope.enableZoom ) handleTouchMoveDolly( event ); + if ( scope.enablePan ) handleTouchMovePan( event ); - function handleTouchMoveDollyPan(event) { - if (scope.enableZoom) { - var dx = event.touches[0].pageX - event.touches[1].pageX; - var dy = event.touches[0].pageY - event.touches[1].pageY; - var distance = Math.sqrt(dx * dx + dy * dy); + } - dollyEnd.set(0, distance); - dollyDelta.set(0, Math.pow(dollyEnd.y / dollyStart.y, scope.zoomSpeed)); - dollyIn(dollyDelta.y); - dollyStart.copy(dollyEnd); - } + function handleTouchMoveDollyRotate( event ) { + if ( scope.enableZoom ) handleTouchMoveDolly( event ); - if (scope.enablePan) { - var x = 0.5 * (event.touches[0].pageX + event.touches[1].pageX); - var y = 0.5 * (event.touches[0].pageY + event.touches[1].pageY); + if ( scope.enableRotate ) handleTouchMoveRotate( event ); - panEnd.set(x, y); - panDelta.subVectors(panEnd, panStart).multiplyScalar(scope.panSpeed); - pan(panDelta.x, panDelta.y); - panStart.copy(panEnd); - } + } - scope.update(); - } + function handleTouchEnd( /*event*/ ) { + // no-op - function handleTouchEnd(event) {} + } + // + // event handlers - FSM: listen for events and reset state + // - // event handlers - listen for events and reset state - function onMouseDown(event) { - if (!scope.enabled) return; + function onMouseDown( event ) { - event.preventDefault(); + if ( scope.enabled === false ) return; - switch (event.button) { - case scope.mouseButtons.ORBIT: - if (!scope.enableRotate) return; - handleMouseDownRotate(event); - state = STATE.ROTATE; - break; + // Prevent the browser from scrolling. - case scope.mouseButtons.ZOOM: - if (!scope.enableZoom) return; - handleMouseDownDolly(event); - state = STATE.DOLLY; - break; + event.preventDefault(); - case scope.mouseButtons.PAN: - if (!scope.enablePan) return; - handleMouseDownPan(event); - state = STATE.PAN; - break; - } + // Manually set the focus since calling preventDefault above + // prevents the browser from setting it automatically. - if (state != STATE.NONE) { - document.addEventListener('mousemove', onMouseMove, false); - document.addEventListener('mouseup', onMouseUp, false); - scope.dispatchEvent(startEvent); - } - } + scope.domElement.focus ? scope.domElement.focus() : window.focus(); + switch ( event.button ) { - function onMouseMove(event) { - if (!scope.enabled) return; + case 0: - event.preventDefault(); + switch ( scope.mouseButtons.LEFT ) { - switch (state) { - case STATE.ROTATE: - if (!scope.enableRotate) return; - handleMouseMoveRotate(event); - break; + case THREE.MOUSE.ROTATE: - case STATE.DOLLY: - if (!scope.enableZoom) return; - handleMouseMoveDolly(event); - break; + if ( event.ctrlKey || event.metaKey || event.shiftKey ) { - case STATE.PAN: - if (!scope.enablePan) return; - handleMouseMovePan(event); - break; - } - } + if ( scope.enablePan === false ) return; + handleMouseDownPan( event ); - function onMouseUp(event) { - if (!scope.enabled) return; + state = STATE.PAN; - handleMouseUp(event); - document.removeEventListener('mousemove', onMouseMove, false); - document.removeEventListener('mouseup', onMouseUp, false); - scope.dispatchEvent(endEvent); - state = STATE.NONE; - } + } else { + if ( scope.enableRotate === false ) return; - function onMouseWheel(event) { - if (!scope.enabled || !scope.enableZoom || - (state != STATE.NONE && state != STATE.ROTATE)) return; + handleMouseDownRotate( event ); - event.preventDefault(); - event.stopPropagation(); - scope.dispatchEvent(startEvent); - handleMouseWheel(event); - scope.dispatchEvent(endEvent); - } + state = STATE.ROTATE; + } - function onKeyDown(event) { - if (!scope.enabled || !scope.enableKeys || !scope.enablePan) return; + break; - handleKeyDown(event); - } + case THREE.MOUSE.PAN: + if ( event.ctrlKey || event.metaKey || event.shiftKey ) { - function onTouchStart(event) { - if (!scope.enabled) return; + if ( scope.enableRotate === false ) return; - event.preventDefault(); + handleMouseDownRotate( event ); - switch (event.touches.length) { - case 1: // one-fingered touch: rotate - if (!scope.enableRotate) return; - handleTouchStartRotate(event); - state = STATE.TOUCH_ROTATE; - break; + state = STATE.ROTATE; - case 2: // two-fingered touch: dolly-pan - if (!scope.enableZoom && !scope.enablePan) return; - handleTouchStartDollyPan(event); - state = STATE.TOUCH_DOLLY_PAN; - break; + } else { - default: state = STATE.NONE; - } + if ( scope.enablePan === false ) return; - if (state != STATE.NONE) scope.dispatchEvent(startEvent); - } + handleMouseDownPan( event ); + state = STATE.PAN; - function onTouchMove(event) { - if (!scope.enabled) return; + } - event.preventDefault(); - event.stopPropagation(); + break; - switch (event.touches.length) { - case 1: // one-fingered touch: rotate - if (!scope.enableRotate) return; - if (state != STATE.TOUCH_ROTATE) return; // is this needed? + default: - handleTouchMoveRotate(event); - break; + state = STATE.NONE; - case 2: // two-fingered touch: dolly-pan - if (!scope.enableZoom && !scope.enablePan) return; - if (state != STATE.TOUCH_DOLLY_PAN) return; // is this needed? + } - handleTouchMoveDollyPan(event); - break; + break; - default: state = STATE.NONE; - } - } + case 1: - function onTouchEnd(event) { - if (!scope.enabled) return; + switch ( scope.mouseButtons.MIDDLE ) { - handleTouchEnd(event); - scope.dispatchEvent(endEvent); - state = STATE.NONE; - } + case THREE.MOUSE.DOLLY: + if ( scope.enableZoom === false ) return; + + handleMouseDownDolly( event ); + + state = STATE.DOLLY; + + break; + + + default: + + state = STATE.NONE; + + } + + break; + + case 2: + + switch ( scope.mouseButtons.RIGHT ) { + + case THREE.MOUSE.ROTATE: + + if ( scope.enableRotate === false ) return; + + handleMouseDownRotate( event ); + + state = STATE.ROTATE; + + break; + + case THREE.MOUSE.PAN: + + if ( scope.enablePan === false ) return; + + handleMouseDownPan( event ); + + state = STATE.PAN; + + break; + + default: + + state = STATE.NONE; + + } + + break; + + } + + if ( state !== STATE.NONE ) { + + document.addEventListener( 'mousemove', onMouseMove, false ); + document.addEventListener( 'mouseup', onMouseUp, false ); + + scope.dispatchEvent( startEvent ); + + } + + } + + function onMouseMove( event ) { + + if ( scope.enabled === false ) return; + + event.preventDefault(); + + switch ( state ) { + + case STATE.ROTATE: + + if ( scope.enableRotate === false ) return; + + handleMouseMoveRotate( event ); + + break; + + case STATE.DOLLY: + + if ( scope.enableZoom === false ) return; + + handleMouseMoveDolly( event ); + + break; + + case STATE.PAN: + + if ( scope.enablePan === false ) return; + + handleMouseMovePan( event ); + + break; + + } + + } + + function onMouseUp( event ) { + + if ( scope.enabled === false ) return; + + handleMouseUp( event ); + + document.removeEventListener( 'mousemove', onMouseMove, false ); + document.removeEventListener( 'mouseup', onMouseUp, false ); + + scope.dispatchEvent( endEvent ); + + state = STATE.NONE; + + } + + function onMouseWheel( event ) { + + if ( scope.enabled === false || scope.enableZoom === false || ( state !== STATE.NONE && state !== STATE.ROTATE ) ) return; + + event.preventDefault(); + event.stopPropagation(); + + scope.dispatchEvent( startEvent ); + + handleMouseWheel( event ); + + scope.dispatchEvent( endEvent ); + + } + + function onKeyDown( event ) { + + if ( scope.enabled === false || scope.enableKeys === false || scope.enablePan === false ) return; + + handleKeyDown( event ); + + } + + function onTouchStart( event ) { + + if ( scope.enabled === false ) return; + + event.preventDefault(); + + switch ( event.touches.length ) { + + case 1: + + switch ( scope.touches.ONE ) { + + case THREE.TOUCH.ROTATE: + + if ( scope.enableRotate === false ) return; + + handleTouchStartRotate( event ); + + state = STATE.TOUCH_ROTATE; + + break; + + case THREE.TOUCH.PAN: + + if ( scope.enablePan === false ) return; + + handleTouchStartPan( event ); + + state = STATE.TOUCH_PAN; + + break; + + default: + + state = STATE.NONE; + + } + + break; + + case 2: + + switch ( scope.touches.TWO ) { + + case THREE.TOUCH.DOLLY_PAN: + + if ( scope.enableZoom === false && scope.enablePan === false ) return; + + handleTouchStartDollyPan( event ); + + state = STATE.TOUCH_DOLLY_PAN; + + break; + + case THREE.TOUCH.DOLLY_ROTATE: + + if ( scope.enableZoom === false && scope.enableRotate === false ) return; + + handleTouchStartDollyRotate( event ); + + state = STATE.TOUCH_DOLLY_ROTATE; + + break; + + default: + + state = STATE.NONE; + + } + + break; + + default: + + state = STATE.NONE; + + } + + if ( state !== STATE.NONE ) { + + scope.dispatchEvent( startEvent ); + + } + + } + + function onTouchMove( event ) { + + if ( scope.enabled === false ) return; + + event.preventDefault(); + event.stopPropagation(); + + switch ( state ) { + + case STATE.TOUCH_ROTATE: + + if ( scope.enableRotate === false ) return; + + handleTouchMoveRotate( event ); + + scope.update(); + + break; + + case STATE.TOUCH_PAN: + + if ( scope.enablePan === false ) return; + + handleTouchMovePan( event ); + + scope.update(); + + break; + + case STATE.TOUCH_DOLLY_PAN: + + if ( scope.enableZoom === false && scope.enablePan === false ) return; + + handleTouchMoveDollyPan( event ); + + scope.update(); + + break; + + case STATE.TOUCH_DOLLY_ROTATE: + + if ( scope.enableZoom === false && scope.enableRotate === false ) return; + + handleTouchMoveDollyRotate( event ); + + scope.update(); + + break; + + default: + + state = STATE.NONE; + + } + + } + + function onTouchEnd( event ) { + + if ( scope.enabled === false ) return; + + handleTouchEnd( event ); + + scope.dispatchEvent( endEvent ); + + state = STATE.NONE; + + } + + function onContextMenu( event ) { + + if ( scope.enabled === false ) return; + + event.preventDefault(); + + } + + // + + scope.domElement.addEventListener( 'contextmenu', onContextMenu, false ); + + scope.domElement.addEventListener( 'mousedown', onMouseDown, false ); + scope.domElement.addEventListener( 'wheel', onMouseWheel, false ); + + scope.domElement.addEventListener( 'touchstart', onTouchStart, false ); + scope.domElement.addEventListener( 'touchend', onTouchEnd, false ); + scope.domElement.addEventListener( 'touchmove', onTouchMove, false ); + + scope.domElement.addEventListener( 'keydown', onKeyDown, false ); + + // make sure element can receive keys. + + if ( scope.domElement.tabIndex === - 1 ) { + + scope.domElement.tabIndex = 0; + + } + + // force an update at start + + this.update(); + +}; + +THREE.OrbitControls.prototype = Object.create( THREE.EventDispatcher.prototype ); +THREE.OrbitControls.prototype.constructor = THREE.OrbitControls; + + +// This set of controls performs orbiting, dollying (zooming), and panning. +// Unlike TrackballControls, it maintains the "up" direction object.up (+Y by default). +// This is very similar to OrbitControls, another set of touch behavior +// +// Orbit - right mouse, or left mouse + ctrl/meta/shiftKey / touch: two-finger rotate +// Zoom - middle mouse, or mousewheel / touch: two-finger spread or squish +// Pan - left mouse, or arrow keys / touch: one-finger move - function onContextMenu(event) { - if (!scope.enabled) return; - event.preventDefault(); - } +THREE.MapControls = function ( object, domElement ) { + THREE.OrbitControls.call( this, object, domElement ); - scope.domElement.addEventListener('contextmenu', onContextMenu, false); - scope.domElement.addEventListener('mousedown', onMouseDown, false); - scope.domElement.addEventListener('wheel', onMouseWheel, false); - scope.domElement.addEventListener('touchstart', onTouchStart, false); - scope.domElement.addEventListener('touchend', onTouchEnd, false); - scope.domElement.addEventListener('touchmove', onTouchMove, false); - window .addEventListener('keydown', onKeyDown, false); + this.mouseButtons.LEFT = THREE.MOUSE.PAN; + this.mouseButtons.RIGHT = THREE.MOUSE.ROTATE; - this.update(); // force an update at start -} + this.touches.ONE = THREE.TOUCH.PAN; + this.touches.TWO = THREE.TOUCH.DOLLY_ROTATE; +}; -OrbitControls.prototype = Object.create(THREE.EventDispatcher.prototype); -OrbitControls.prototype.constructor = OrbitControls; -module.exports = OrbitControls; +THREE.MapControls.prototype = Object.create( THREE.EventDispatcher.prototype ); +THREE.MapControls.prototype.constructor = THREE.MapControls; diff --git a/src/js/path-viewer.js b/src/js/path-viewer.js index b654503..2c113e8 100644 --- a/src/js/path-viewer.js +++ b/src/js/path-viewer.js @@ -27,10 +27,10 @@ 'use strict' -var orbit = require('./orbit'); -var cookie = require('./cookie')('bbctrl-'); -var api = require('./api'); -var font = require('./helvetiker_regular.typeface.json') +var orbit = require('./orbit'); +var cookie = require('./cookie'); +var api = require('./api'); +var font = require('./helvetiker_regular.typeface.json') function get(obj, name, defaultValue) { @@ -38,12 +38,18 @@ function get(obj, name, defaultValue) { } +function sizeOf(obj) { + obj.geometry.computeBoundingBox(); + return obj.geometry.boundingBox.getSize(new THREE.Vector3()); +} + + var surfaceModes = ['cut', 'wire', 'solid', 'off']; module.exports = { template: '#path-viewer-template', - props: ['toolpath'], + props: ['toolpath', 'state', 'config'], data: function () { @@ -51,64 +57,52 @@ module.exports = { enabled: false, loading: false, dirty: true, - snapView: cookie.get('snap-view', 'isometric'), - small: cookie.get_bool('small-path-view', true), + snapView: cookie.get('snap-view', 'angled'), surfaceMode: 'cut', - showPath: cookie.get_bool('show-path', true), - showTool: cookie.get_bool('show-tool', true), - showBBox: cookie.get_bool('show-bbox', true), - showAxes: cookie.get_bool('show-axes', true), - showIntensity: cookie.get_bool('show-intensity', false) + axes: {}, + show: { + path: cookie.get_bool('show-path', true), + tool: cookie.get_bool('show-tool', true), + bbox: cookie.get_bool('show-bbox', true), + axes: cookie.get_bool('show-axes', true), + grid: cookie.get_bool('show-grid', true), + dims: cookie.get_bool('show-dims', true), + intensity: cookie.get_bool('show-intensity', false) + } } }, computed: { - target: function () {return $(this.$el).find('.path-viewer-content')[0]} - }, - - - watch: { - toolpath: function () {Vue.nextTick(this.update)}, - surfaceMode: function (mode) {this.update_surface_mode(mode)}, - - - small: function (enable) { - cookie.set_bool('small-path-view', enable); - Vue.nextTick(this.update_view) - }, - - - showPath: function (enable) { - cookie.set_bool('show-path', enable); - this.set_visible(this.pathView, enable) - }, + target: function () {return $(this.$el).find('.path-viewer-content')[0]}, - showTool: function (enable) { - cookie.set_bool('show-tool', enable); - this.set_visible(this.toolView, enable) + metric: function () { + return this.config.settings.units.toLowerCase() == 'metric'; }, - showAxes: function (enable) { - cookie.set_bool('show-axes', enable); - this.set_visible(this.axesView, enable) - }, + envelope: function () { + if (!this.axes.homed || !this.enabled) return undefined; + var min = new THREE.Vector3(); + var max = new THREE.Vector3(); - showIntensity: function (enable) { - cookie.set_bool('show-intensity', enable); - Vue.nextTick(this.update) - }, + for (var axis of 'xyz') { + min[axis] = this[axis].min - this[axis].off; + max[axis] = this[axis].max - this[axis].off; + } + return new THREE.Box3(min, max); + } + }, - showBBox: function (enable) { - cookie.set_bool('show-bbox', enable); - this.set_visible(this.bboxView, enable); - this.set_visible(this.envelopeView, enable); - }, + watch: { + toolpath: function () {Vue.nextTick(this.update)}, + envelope: function () {Vue.nextTick(this.redraw)}, + metric: function () {Vue.nextTick(this.redraw)}, + surfaceMode: function (mode) {this.update_surface_mode(mode)}, x: function () {this.axis_changed()}, y: function () {this.axis_changed()}, @@ -123,49 +117,55 @@ module.exports = { methods: { - update: function () { - if (!this.state.selected) { - this.dirty = true; - this.scene = new THREE.Scene(); + setShow: function (name, show) { + this.show[name] = show; + cookie.set_bool('show-' + name, show); - } else if (!this.toolpath.filename && !this.loading) { - this.loading = true; - this.dirty = true; - this.draw_loading(); - } + if (name == 'path') this.pathView.visible = show; + if (name == 'tool') this.toolView.visible = show; + if (name == 'axes') this.axesView.visible = show; + if (name == 'grid') this.gridView.visible = show; + if (name == 'dims') this.dimsView.visible = show; + if (name == 'intensity') Vue.nextTick(this.redraw) + this.render_frame(); + }, + + + getShow: function (name) {return this.show[name]}, + toggle: function (name) {this.setShow(name, !this.getShow(name))}, - if (!this.enabled || !this.toolpath.filename) return; - function get(url) { - var d = $.Deferred(); - var xhr = new XMLHttpRequest(); + clear: function () { + this.scene = new THREE.Scene(); + if (this.renderer != undefined) this.render_frame(); + }, - xhr.open('GET', url + '?' + Math.random(), true); - xhr.responseType = 'arraybuffer'; - xhr.onload = function (e) { - if (xhr.response) d.resolve(new Float32Array(xhr.response)); - else d.reject(); - }; + redraw: function () { + if (!this.enabled || this.loading) return; + this.scene = new THREE.Scene(); + this.draw(this.scene); + }, - xhr.send(); - return d.promise(); + update: function () { + if (!this.toolpath.path && !this.loading) { + this.loading = true; + this.dirty = true; } - var d1 = get('/api/path/' + this.toolpath.filename + '/positions'); - var d2 = get('/api/path/' + this.toolpath.filename + '/speeds'); + if (!this.enabled || !this.toolpath.path) return; + + var path = this.toolpath.path; + var d1 = api.download('positions/' + path, 'arraybuffer'); + var d2 = api.download('speeds/' + path, 'arraybuffer'); $.when(d1, d2).done(function (positions, speeds) { - this.positions = positions - this.speeds = speeds; + this.positions = new Float32Array(positions[0]); + this.speeds = new Float32Array(speeds[0]); this.loading = false; - - // Update scene - this.scene = new THREE.Scene(); - this.draw(this.scene); + this.redraw(); this.snap(this.snapView); - this.update_view(); }.bind(this)) }, @@ -223,13 +223,6 @@ module.exports = { this.camera.aspect = dims.width / dims.height; this.camera.updateProjectionMatrix(); this.renderer.setSize(dims.width, dims.height); - - if (this.loading) { - this.controls.reset(); - this.camera.position.copy(new THREE.Vector3(0, 0, 600)); - this.camera.lookAt(new THREE.Vector3(0, 0, 0)); - } - this.dirty = true; }, @@ -244,28 +237,9 @@ module.exports = { }, - update_envelope: function (envelope) { - if (!this.enabled || !this.axes.homed) return; - if (typeof envelope == 'undefined') envelope = this.envelopeView; - if (typeof envelope == 'undefined') return; - - var min = new THREE.Vector3(); - var max = new THREE.Vector3(); - - for (var axis of 'xyz') { - min[axis] = this[axis].min - this[axis].off; - max[axis] = this[axis].max - this[axis].off; - } - - var bounds = new THREE.Box3(min, max); - if (bounds.isEmpty()) envelope.geometry = this.create_empty_geom(); - else envelope.geometry = this.create_bbox_geom(bounds); - }, - - axis_changed: function () { + if (!this.enabled) return; this.update_tool(); - this.update_envelope(); this.dirty = true; }, @@ -285,7 +259,8 @@ module.exports = { this.enabled = true; // Camera - this.camera = new THREE.PerspectiveCamera(45, 4 / 3, 1, 10000); + this.camera = new THREE.PerspectiveCamera(45, 1, 1, 10000); + this.camera.up.set(0, 0, 1); // Lighting this.ambient = new THREE.AmbientLight(0xffffff, 0.5); @@ -310,11 +285,11 @@ module.exports = { this.surfaceMaterial = this.create_surface_material(); // Controls - this.controls = new orbit(this.camera, this.renderer.domElement); + this.controls = + new THREE.OrbitControls(this.camera, this.renderer.domElement); this.controls.enableDamping = true; this.controls.dampingFactor = 0.2; - this.controls.rotateSpeed = 0.25; - this.controls.enableZoom = true; + this.controls.rotateSpeed = 0.5; // Move lights with scene this.controls.addEventListener('change', function (scope) { @@ -347,33 +322,8 @@ module.exports = { }, - draw_loading: function () { - this.scene = new THREE.Scene(); - - var geometry = new THREE.TextGeometry('Loading 3D View...', { - font: new THREE.Font(font), - size: 40, - height: 5, - curveSegments: 12, - bevelEnabled: true, - bevelThickness: 10, - bevelSize: 8, - bevelSegments: 5 - }); - geometry.computeBoundingBox(); - - var center = geometry.center(); - var mesh = new THREE.Mesh(geometry, this.surfaceMaterial); - - this.scene.add(mesh); - this.scene.add(this.ambient); - this.scene.add(this.lights); - this.update_view(); - }, - - draw_workpiece: function (scene, material) { - if (typeof this.workpiece == 'undefined') return; + if (typeof this.workpiece == 'undefined') return undefined; var min = this.workpiece.min; var max = this.workpiece.max; @@ -437,7 +387,7 @@ module.exports = { var mesh = new THREE.Mesh(geometry, material); this.update_tool(mesh); - mesh.visible = this.showTool; + mesh.visible = this.show.tool; scene.add(mesh); return mesh; }, @@ -485,9 +435,113 @@ module.exports = { for (var up = 0; up < 2; up++) group.add(this.draw_axis(axis, up, length, radius)); - group.visible = this.showAxes; + group.visible = this.show.axes; + scene.add(group); + + return group; + }, + + + draw_grid: function (scene, bbox) { + // Grid size is relative to bounds + var size = bbox.getSize(new THREE.Vector3()); + size = Math.max(size.x, size.y) * 16; + var step = this.metric ? 10 : 25.4; + var divs = Math.ceil(size / step); + size = divs * step; + + var material = new THREE.MeshPhongMaterial({ + shininess: 0, + specular: 0, + color: 0, + opacity: 0.2, + transparent: true + }); + + var grid = new THREE.GridHelper(size, divs); + grid.material = material; + grid.rotation.x = Math.PI / 2; + + scene.add(grid); + + return grid; + }, + + + draw_text: function (text, size, color) { + var geometry = new THREE.TextGeometry(text, { + font: new THREE.Font(font), + size: size, + height: 0.001, + curveSegments: 12, + bevelEnabled: false + }); + + var material = new THREE.MeshBasicMaterial({color: color}); + + return new THREE.Mesh(geometry, material); + }, + + + format_dim(dim) { + if (!this.metric) dim /= 25.4; + return dim.toFixed(1) + (this.metric ? ' mm' : ' in'); + }, + + + draw_box_dims: function (bounds, color) { + var group = new THREE.Group(); + + var dims = bounds.getSize(new THREE.Vector3()); + var size = Math.max(dims.x, dims.y, dims.z) / 40; + + var xDim = this.draw_text(this.format_dim(dims.x), size, color); + xDim.position.x = bounds.min.x + (dims.x - sizeOf(xDim).x) / 2; + xDim.position.y = bounds.max.y + size; + xDim.position.z = bounds.max.z; + group.add(xDim); + + var yDim = this.draw_text(this.format_dim(dims.y), size, color); + yDim.position.x = bounds.max.x + size; + yDim.position.y = bounds.min.y + (dims.y + sizeOf(yDim).x) / 2; + yDim.position.z = bounds.max.z; + yDim.rotateZ(-Math.PI / 2); + group.add(yDim); + + var zDim = this.draw_text(this.format_dim(dims.z), size, color); + zDim.position.x = bounds.max.x + size; + zDim.position.y = bounds.max.y + zDim.position.z = bounds.min.z + (dims.z - sizeOf(zDim).y) / 2; + zDim.rotateX(Math.PI / 2); + group.add(zDim); + + var material = new THREE.LineBasicMaterial({ + linewidth: 2, + color: color, + opacity: 0.4, + transparent: true + }); + + var box = new THREE.Box3Helper(bounds); + box.material = material; + group.add(box); + + return group; + }, + + + draw_dims: function (scene, bbox) { + var group = new THREE.Group(); + group.visible = this.show.dims; scene.add(group); + // Bounds + group.add(this.draw_box_dims(bbox, 0x0c2d53)); + + // Envelope + if (this.envelope) + group.add(this.draw_box_dims(this.envelope, 0x00f7ff)); + return group; }, @@ -496,7 +550,7 @@ module.exports = { if (isNaN(speed)) return [255, 0, 0]; // Rapid var intensity = speed / this.toolpath.maxSpeed; - if (typeof speed == 'undefined' || !this.showIntensity) intensity = 1; + if (typeof speed == 'undefined' || !this.show.intensity) intensity = 1; return [0, 255 * intensity, 127 * (1 - intensity)]; }, @@ -526,87 +580,8 @@ module.exports = { var line = new THREE.Line(geometry, material); - line.visible = this.showPath; - scene.add(line); - - return line; - }, - - - create_empty_geom: function () { - var geometry = new THREE.BufferGeometry(); - geometry.addAttribute('position', - new THREE.Float32BufferAttribute([], 3)); - return geometry; - }, - - - create_bbox_geom: function (bbox) { - var vertices = []; - - if (!bbox.isEmpty()) { - // Top - vertices.push(bbox.min.x, bbox.min.y, bbox.min.z); - vertices.push(bbox.max.x, bbox.min.y, bbox.min.z); - vertices.push(bbox.max.x, bbox.min.y, bbox.min.z); - vertices.push(bbox.max.x, bbox.min.y, bbox.max.z); - vertices.push(bbox.max.x, bbox.min.y, bbox.max.z); - vertices.push(bbox.min.x, bbox.min.y, bbox.max.z); - vertices.push(bbox.min.x, bbox.min.y, bbox.max.z); - vertices.push(bbox.min.x, bbox.min.y, bbox.min.z); - - // Bottom - vertices.push(bbox.min.x, bbox.max.y, bbox.min.z); - vertices.push(bbox.max.x, bbox.max.y, bbox.min.z); - vertices.push(bbox.max.x, bbox.max.y, bbox.min.z); - vertices.push(bbox.max.x, bbox.max.y, bbox.max.z); - vertices.push(bbox.max.x, bbox.max.y, bbox.max.z); - vertices.push(bbox.min.x, bbox.max.y, bbox.max.z); - vertices.push(bbox.min.x, bbox.max.y, bbox.max.z); - vertices.push(bbox.min.x, bbox.max.y, bbox.min.z); - - // Sides - vertices.push(bbox.min.x, bbox.min.y, bbox.min.z); - vertices.push(bbox.min.x, bbox.max.y, bbox.min.z); - vertices.push(bbox.max.x, bbox.min.y, bbox.min.z); - vertices.push(bbox.max.x, bbox.max.y, bbox.min.z); - vertices.push(bbox.max.x, bbox.min.y, bbox.max.z); - vertices.push(bbox.max.x, bbox.max.y, bbox.max.z); - vertices.push(bbox.min.x, bbox.min.y, bbox.max.z); - vertices.push(bbox.min.x, bbox.max.y, bbox.max.z); - } - - var geometry = new THREE.BufferGeometry(); - - geometry.addAttribute('position', - new THREE.Float32BufferAttribute(vertices, 3)); - - return geometry; - }, - - - draw_bbox: function (scene, bbox) { - var geometry = this.create_bbox_geom(bbox); - var material = new THREE.LineBasicMaterial({color: 0xffffff}); - var line = new THREE.LineSegments(geometry, material); - - line.visible = this.showBBox; - - scene.add(line); - - return line; - }, - - - draw_envelope: function (scene) { - var geometry = this.create_empty_geom(); - var material = new THREE.LineBasicMaterial({color: 0x00f7ff}); - var line = new THREE.LineSegments(geometry, material); - - line.visible = this.showBBox; - + line.visible = this.show.path; scene.add(line); - this.update_envelope(line); return line; }, @@ -618,8 +593,8 @@ module.exports = { scene.add(this.lights); // Model - this.pathView = this.draw_path(scene); - this.surfaceMesh = this.draw_surface(scene, this.surfaceMaterial); + this.pathView = this.draw_path(scene); + this.surfaceMesh = this.draw_surface(scene, this.surfaceMaterial); this.workpieceMesh = this.draw_workpiece(scene, this.surfaceMaterial); this.update_surface_mode(this.surfaceMode); @@ -627,33 +602,36 @@ module.exports = { var bbox = this.get_model_bounds(); // Tool, axes & bounds - this.toolView = this.draw_tool(scene, bbox); - this.axesView = this.draw_axes(scene, bbox); - this.bboxView = this.draw_bbox(scene, bbox); - this.envelopeView = this.draw_envelope(scene); + this.toolView = this.draw_tool(scene, bbox); + this.axesView = this.draw_axes(scene, bbox); + this.gridView = this.draw_grid(scene, bbox); + this.dimsView = this.draw_dims(scene, bbox); }, + render_frame: function () {this.renderer.render(this.scene, this.camera)}, + + render: function () { window.requestAnimationFrame(this.render); if (typeof this.scene == 'undefined') return; if (this.controls.update() || this.dirty) { this.dirty = false; - this.renderer.render(this.scene, this.camera); + this.render_frame(); } }, get_model_bounds: function () { - var bbox = new THREE.Box3(new THREE.Vector3(0, 0, 0), - new THREE.Vector3(0.00001, 0.00001, 0.00001)); + var bbox = undefined; function add(o) { if (typeof o != 'undefined') { var oBBox = new THREE.Box3(); oBBox.setFromObject(o); - bbox.union(oBBox); + if (bbox == undefined) bbox = oBBox; + else bbox.union(oBBox); } } @@ -673,6 +651,17 @@ module.exports = { } var bbox = this.get_model_bounds(); + var corners = [ + new THREE.Vector3(bbox.min.x, bbox.min.y, bbox.min.z), + new THREE.Vector3(bbox.min.x, bbox.min.y, bbox.max.z), + new THREE.Vector3(bbox.min.x, bbox.max.y, bbox.min.z), + new THREE.Vector3(bbox.min.x, bbox.max.y, bbox.max.z), + new THREE.Vector3(bbox.max.x, bbox.min.y, bbox.min.z), + new THREE.Vector3(bbox.max.x, bbox.min.y, bbox.max.z), + new THREE.Vector3(bbox.max.x, bbox.max.y, bbox.min.z), + new THREE.Vector3(bbox.max.x, bbox.max.y, bbox.max.z), + ] + this.controls.reset(); bbox.getCenter(this.controls.target); this.update_view(); @@ -681,17 +670,17 @@ module.exports = { var center = bbox.getCenter(new THREE.Vector3()); var offset = new THREE.Vector3(); - if (view == 'isometric') {offset.y -= 1; offset.z += 1;} + if (view == 'angled') {offset.y -= 1; offset.z += 1;} if (view == 'front') offset.y -= 1; if (view == 'back') offset.y += 1; - if (view == 'left') offset.x -= 1; - if (view == 'right') offset.x += 1; + if (view == 'left') {offset.x -= 1; offset.z += 0.0001;} + if (view == 'right') {offset.x += 1; offset.z += 0.0001;} if (view == 'top') offset.z += 1; if (view == 'bottom') offset.z -= 1; offset.normalize(); // Initial camera position - var position = new THREE.Vector3().copy(center).add(offset); + var position = center.clone().add(offset); this.camera.position.copy(position); this.camera.lookAt(center); // Get correct camera orientation @@ -702,17 +691,6 @@ module.exports = { var cameraLeft = new THREE.Vector3().copy(offset).cross(cameraUp).normalize(); - var corners = [ - new THREE.Vector3(bbox.min.x, bbox.min.y, bbox.min.z), - new THREE.Vector3(bbox.min.x, bbox.min.y, bbox.max.z), - new THREE.Vector3(bbox.min.x, bbox.max.y, bbox.min.z), - new THREE.Vector3(bbox.min.x, bbox.max.y, bbox.max.z), - new THREE.Vector3(bbox.max.x, bbox.min.y, bbox.min.z), - new THREE.Vector3(bbox.max.x, bbox.min.y, bbox.max.z), - new THREE.Vector3(bbox.max.x, bbox.max.y, bbox.min.z), - new THREE.Vector3(bbox.max.x, bbox.max.y, bbox.max.z), - ] - var dist = this.camera.near; // Min camera dist for (var i = 0; i < corners.length; i++) { @@ -735,7 +713,7 @@ module.exports = { var l = p1.distanceTo(p2); // Update min camera distance - dist = Math.max(dist, d + l / Math.tan(theta / 2)); + dist = Math.max(dist, d + l / Math.tan(theta / 2) / this.camera.aspect); // Compute left line var left = @@ -749,7 +727,7 @@ module.exports = { l = p1.distanceTo(p3); // Update min camera distance - dist = Math.max(dist, d + l / Math.tan(theta / 2) / this.camera.aspect); + dist = Math.max(dist, d + l / Math.tan(theta / 2)); } this.camera.position.copy(offset.multiplyScalar(dist * 1.2).add(center)); diff --git a/src/js/admin-general-view.js b/src/js/settings-admin.js similarity index 61% rename from src/js/admin-general-view.js rename to src/js/settings-admin.js index 20d3336..d838e8e 100644 --- a/src/js/admin-general-view.js +++ b/src/js/settings-admin.js @@ -32,26 +32,24 @@ var api = require('./api'); module.exports = { - template: '#admin-general-view-template', + template: '#settings-admin-template', props: ['config', 'state'], data: function () { return { - configRestored: false, - confirmReset: false, - configReset: false, - latest: '', - autoCheckUpgrade: true + autoCheckUpgrade: true, + password: '', + firmwareName: '', + show: { + upgrade: false, + upgrading: false, + upload: false + } } }, - events: { - latest_version: function (version) {this.latest = version} - }, - - ready: function () { this.autoCheckUpgrade = this.config.admin['auto-check-upgrade'] }, @@ -82,37 +80,62 @@ module.exports = { try { config = JSON.parse(e.target.result); } catch (ex) { - api.alert("Invalid config file"); + this.$root.error_dialog("Invalid config file"); return; } api.put('config/save', config).done(function (data) { this.$dispatch('update'); - this.configRestored = true; + this.$root.success_dialog('Configuration restored.'); }.bind(this)).fail(function (error) { - api.alert('Restore failed', error); - }) + this.$root.api_error('Restore failed', error); + }.bind(this)) }.bind(this); fr.readAsText(files[0]); }, - reset: function () { - this.confirmReset = false; + do_reset: function () { api.put('config/reset').done(function () { this.$dispatch('update'); - this.configReset = true; + this.$root.success_dialog('Configuration reset.') }.bind(this)).fail(function (error) { - api.alert('Reset failed', error); - }); + this.$root.api_error('Reset failed', error); + }.bind(this)); + }, + + + reset: function () { + this.$root.open_dialog({ + title: 'Reset to default configuration?', + body: 'Non-network configuration changes will be lost.', + buttons: 'Cancel OK', + callback: {ok: this.do_reset} + }) + }, + + check: function () {this.$dispatch('check', true)}, + + + upgrade: function () { + this.password = ''; + this.show.upgrade = true; }, - check: function () {this.$dispatch('check')}, - upgrade: function () {this.$dispatch('upgrade')}, + upgrade_confirmed: function () { + this.show.upgrade = false; + + api.put('upgrade', {password: this.password}).done(function () { + this.show.upgrading = true; + + }.bind(this)).fail(function () { + this.error_dialog('Invalid password'); + }.bind(this)) + }, upload_firmware: function () { @@ -126,7 +149,35 @@ module.exports = { upload: function (e) { var files = e.target.files || e.dataTransfer.files; if (!files.length) return; - this.$dispatch('upload', files[0]); + + this.firmware = files[0]; + this.firmwareName = files[0].name; + this.password = ''; + this.show.upload = true; + }, + + + upload_confirmed: function () { + this.show.upload = false; + + var form = new FormData(); + form.append('firmware', this.firmware); + if (this.password) form.append('password', this.password); + + $.ajax({ + url: '/api/firmware/update', + type: 'PUT', + data: form, + cache: false, + contentType: false, + processData: false + + }).success(function () { + this.show.upgrading = true; + + }.bind(this)).error(function () { + this.error_dialog('Invalid password or bad firmware'); + }.bind(this)) }, diff --git a/src/js/settings-general.js b/src/js/settings-general.js new file mode 100644 index 0000000..f292049 --- /dev/null +++ b/src/js/settings-general.js @@ -0,0 +1,34 @@ +/******************************************************************************\ + + This file is part of the Buildbotics firmware. + + Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. + + This Source describes Open Hardware and is licensed under the + CERN-OHL-S v2. + + You may redistribute and modify this Source and make products + using it under the terms of the CERN-OHL-S v2 (https:/cern.ch/cern-ohl). + This Source is distributed WITHOUT ANY EXPRESS OR IMPLIED + WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY AND FITNESS + FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-S v2 for applicable + conditions. + + Source location: https://github.com/buildbotics + + As per CERN-OHL-S v2 section 4, should You produce hardware based on + these sources, You must maintain the Source Location clearly visible on + the external case of the CNC Controller or other product you make using + this Source. + + For more information, email info@buildbotics.com + +\******************************************************************************/ + +'use strict' + + +module.exports = { + template: '#settings-general-template', + props: ['config', 'template'] +} diff --git a/src/js/settings-io.js b/src/js/settings-io.js new file mode 100644 index 0000000..92bd280 --- /dev/null +++ b/src/js/settings-io.js @@ -0,0 +1,34 @@ +/******************************************************************************\ + + This file is part of the Buildbotics firmware. + + Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. + + This Source describes Open Hardware and is licensed under the + CERN-OHL-S v2. + + You may redistribute and modify this Source and make products + using it under the terms of the CERN-OHL-S v2 (https:/cern.ch/cern-ohl). + This Source is distributed WITHOUT ANY EXPRESS OR IMPLIED + WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY AND FITNESS + FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-S v2 for applicable + conditions. + + Source location: https://github.com/buildbotics + + As per CERN-OHL-S v2 section 4, should You produce hardware based on + these sources, You must maintain the Source Location clearly visible on + the external case of the CNC Controller or other product you make using + this Source. + + For more information, email info@buildbotics.com + +\******************************************************************************/ + +'use strict' + + +module.exports = { + template: '#settings-io-template', + props: ['config', 'template', 'state'] +} diff --git a/src/js/motor-view.js b/src/js/settings-motor.js similarity index 98% rename from src/js/motor-view.js rename to src/js/settings-motor.js index 7120b85..db56e2e 100644 --- a/src/js/motor-view.js +++ b/src/js/settings-motor.js @@ -29,7 +29,7 @@ module.exports = { - template: '#motor-view-template', + template: '#settings-motor-template', props: ['index', 'config', 'template', 'state'], @@ -125,8 +125,7 @@ module.exports = { this.$dispatch('config-changed'); }.bind(this)) - - return false; + return true; } }, diff --git a/src/js/admin-network-view.js b/src/js/settings-network.js similarity index 83% rename from src/js/admin-network-view.js rename to src/js/settings-network.js index bf029cf..14911be 100644 --- a/src/js/admin-network-view.js +++ b/src/js/settings-network.js @@ -32,15 +32,13 @@ var api = require('./api'); module.exports = { - template: '#admin-network-view-template', + template: '#settings-network-template', props: ['config', 'state'], data: function () { return { hostnameSet: false, - usernameSet: false, - passwordSet: false, redirectTimeout: 0, hostname: '', username: '', @@ -102,28 +100,28 @@ module.exports = { }.bind(this)); }.bind(this)).fail(function (error) { - api.alert('Set hostname failed', error); - }) + this.$root.api_error('Set hostname failed', error); + }.bind(this)) }, set_username: function () { api.put('remote/username', {username: this.username}).done(function () { - this.usernameSet = true; + this.$root.open_dialog({title: 'User name Set'}); }.bind(this)).fail(function (error) { - api.alert('Set username failed', error); - }) + this.$root.api_error('Set username failed', error); + }.bind(this)) }, set_password: function () { if (this.password != this.password2) { - alert('Passwords to not match'); + this.$root.error_dialog('Passwords to not match'); return; } if (this.password.length < 6) { - alert('Password too short'); + this.$root.error_dialog('Password too short'); return; } @@ -131,10 +129,11 @@ module.exports = { current: this.current, password: this.password }).done(function () { - this.passwordSet = true; + this.$root.open_dialog({title: 'Password Set'}); + }.bind(this)).fail(function (error) { - api.alert('Set password failed', error); - }) + this.$root.api_error('Set password failed', error); + }.bind(this)) }, @@ -142,22 +141,22 @@ module.exports = { this.wifiConfirm = false; if (!this.wifi_ssid.length) { - alert('SSID not set'); + this.$root.error_dialog('SSID not set'); return; } if (32 < this.wifi_ssid.length) { - alert('SSID longer than 32 characters'); + this.$root.error_dialog('SSID longer than 32 characters'); return; } if (this.wifi_pass.length && this.wifi_pass.length < 8) { - alert('WiFi password shorter than 8 characters'); + this.$root.error_dialog('WiFi password shorter than 8 characters'); return; } if (128 < this.wifi_pass.length) { - alert('WiFi password longer than 128 characters'); + this.$root.error_dialog('WiFi password longer than 128 characters'); return; } @@ -172,7 +171,7 @@ module.exports = { } api.put('wifi', config).fail(function (error) { - api.alert('Failed to configure WiFi', error); + this.$root.api_error('Failed to configure WiFi', error); this.rebooting = false; }.bind(this)) } diff --git a/src/js/tool-view.js b/src/js/settings-tool.js similarity index 88% rename from src/js/tool-view.js rename to src/js/settings-tool.js index 5975fc6..f596789 100644 --- a/src/js/tool-view.js +++ b/src/js/settings-tool.js @@ -32,7 +32,7 @@ var modbus = require('./modbus.js'); module.exports = { - template: '#tool-view-template', + template: '#settings-tool-template', props: ['config', 'template', 'state'], @@ -52,14 +52,6 @@ module.exports = { }, - events: { - 'input-changed': function() { - this.$dispatch('config-changed'); - return false; - } - }, - - ready: function () {this.value = this.state.mr}, @@ -99,20 +91,7 @@ module.exports = { }, - read: function (e) { - e.preventDefault(); - api.put('modbus/read', {address: this.address}); - }, - - - write: function (e) { - e.preventDefault(); - api.put('modbus/write', {address: this.address, value: this.value}); - }, - - customize: function (e) { - e.preventDefault(); this.config.tool['tool-type'] = 'Custom Modbus VFD'; var regs = this.config['modbus-spindle'].regs; @@ -128,7 +107,6 @@ module.exports = { clear: function (e) { - e.preventDefault(); this.config.tool['tool-type'] = 'Custom Modbus VFD'; var regs = this.config['modbus-spindle'].regs; @@ -143,7 +121,6 @@ module.exports = { reset_failures: function (e) { - e.preventDefault(); var regs = this.config['modbus-spindle'].regs; for (var reg = 0; reg < regs.length; reg++) this.$dispatch('send', '\$' + reg + 'vr=0'); diff --git a/src/js/sock.js b/src/js/sock.js index 6350997..eae2ab6 100644 --- a/src/js/sock.js +++ b/src/js/sock.js @@ -56,7 +56,7 @@ Sock.prototype.connect = function () { this._sock = new SockJS(this.url); this._sock.onmessage = function (e) { - console.debug('msg:', e.data); + //console.debug('msg:', e.data); this.heartbeat('msg'); this.onmessage(e); }.bind(this); diff --git a/src/js/util.js b/src/js/util.js new file mode 100644 index 0000000..57463d5 --- /dev/null +++ b/src/js/util.js @@ -0,0 +1,115 @@ +/******************************************************************************\ + + This file is part of the Buildbotics firmware. + + Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. + + This Source describes Open Hardware and is licensed under the + CERN-OHL-S v2. + + You may redistribute and modify this Source and make products + using it under the terms of the CERN-OHL-S v2 (https:/cern.ch/cern-ohl). + This Source is distributed WITHOUT ANY EXPRESS OR IMPLIED + WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY AND FITNESS + FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-S v2 for applicable + conditions. + + Source location: https://github.com/buildbotics + + As per CERN-OHL-S v2 section 4, should You produce hardware based on + these sources, You must maintain the Source Location clearly visible on + the external case of the CNC Controller or other product you make using + this Source. + + For more information, email info@buildbotics.com + +\******************************************************************************/ + +'use strict'; + + +var util = { + SEC_PER_YEAR: 365 * 24 * 60 * 60, + SEC_PER_MONTH: 30 * 24 * 60 * 60, + SEC_PER_WEEK: 7 * 24 * 60 * 60, + SEC_PER_DAY: 24 * 60 * 60, + SEC_PER_HOUR: 60 * 60, + SEC_PER_MIN: 60, + uuid_chars: + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_+', + + + duration: function (x, name, precision) { + x = x.toFixed(typeof precision == 'undefined' ? 0 : precision); + return x + ' ' + name + (x == 1 ? '' : 's') + }, + + + human_duration: function (x, precision) { + if (util.SEC_PER_YEAR <= x) + return util.duration(x / util.SEC_PER_YEAR, 'year', precision); + if (util.SEC_PER_MONTH <= x) + return util.duration(x / util.SEC_PER_MONTH, 'month', precision); + if (util.SEC_PER_WEEK <= x) + return util.duration(x / util.SEC_PER_WEEK, 'week', precision); + if (util.SEC_PER_DAY <= x) + return util.duration(x / util.SEC_PER_DAY, 'day', precision); + if (util.SEC_PER_HOUR <= x) + return util.duration(x / util.SEC_PER_HOUR, 'hour', precision); + if (util.SEC_PER_MIN <= x) + return util.duration(x / util.SEC_PER_MIN, 'min', precision); + return util.duration(x, 'sec', precision); + }, + + + human_size: function (x, precision) { + if (typeof precision == 'undefined') precision = 1; + + if (1e12 <= x) return (x / 1e12).toFixed(precision) + 'T' + if (1e9 <= x) return (x / 1e9 ).toFixed(precision) + 'B' + if (1e6 <= x) return (x / 1e6 ).toFixed(precision) + 'M' + if (1e3 <= x) return (x / 1e3 ).toFixed(precision) + 'K' + return x; + }, + + + unix_path: function (path) { + if (/Win/i.test(navigator.platform)) return path.replace('\\', '/'); + return path; + }, + + + dirname: function (path) { + var sep = path.lastIndexOf('/'); + return sep == -1 ? '.' : (sep == 0 ? '/' : path.substr(0, sep)); + }, + + + basename: function (path) {return path.substr(path.lastIndexOf('/') + 1)}, + + + join_path: function (a, b) { + if (!a) return b; + return a[a.length - 1] == '/' ? a + b : (a + '/' + b); + }, + + + display_path: function (path) { + if (path == undefined) return path; + return path.startsWith('Home/') ? path.substr(5) : path; + }, + + + uuid: function (length) { + if (typeof length == 'undefined') length = 52; + + var s = ''; + for (var i = 0; i < length; i++) + s += util.uuid_chars[Math.floor(Math.random() * util.uuid_chars.length)]; + + return s + } +} + + +module.exports = util; diff --git a/src/js/video.js b/src/js/video.js new file mode 100644 index 0000000..ba8cbd9 --- /dev/null +++ b/src/js/video.js @@ -0,0 +1,59 @@ +/******************************************************************************\ + + This file is part of the Buildbotics firmware. + + Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. + + This Source describes Open Hardware and is licensed under the + CERN-OHL-S v2. + + You may redistribute and modify this Source and make products + using it under the terms of the CERN-OHL-S v2 (https:/cern.ch/cern-ohl). + This Source is distributed WITHOUT ANY EXPRESS OR IMPLIED + WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY AND FITNESS + FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-S v2 for applicable + conditions. + + Source location: https://github.com/buildbotics + + As per CERN-OHL-S v2 section 4, should You produce hardware based on + these sources, You must maintain the Source Location clearly visible on + the external case of the CNC Controller or other product you make using + this Source. + + For more information, email info@buildbotics.com + +\******************************************************************************/ + +'use strict' + + +module.exports = { + template: '#video-template', + + + ready: function () { + Vue.nextTick(this.resize); + window.addEventListener('resize', this.resize, false); + }, + + + methods: { + reload: function () {this.$els.img.src = '/api/video?' + Math.random()}, + + + resize: function () { + var width = this.$els.video.clientWidth; + var height = this.$els.video.clientHeight; + var aspect = 480 / 640; // TODO should probably not be hard coded + + if (!width) return; + + width = Math.min(width, height / aspect); + height = Math.min(height, width * aspect); + + this.$els.img.style.width = width + 'px'; + this.$els.img.style.height = height + 'px'; + } + } +} diff --git a/src/js/control-view.js b/src/js/view-control.js similarity index 71% rename from src/js/control-view.js rename to src/js/view-control.js index 4d78306..326439d 100644 --- a/src/js/control-view.js +++ b/src/js/view-control.js @@ -28,7 +28,8 @@ 'use strict' var api = require('./api'); -var cookie = require('./cookie')('bbctrl-'); +var cookie = require('./cookie'); +var util = require('./util'); function _is_array(x) { @@ -43,7 +44,7 @@ function escapeHTML(s) { module.exports = { - template: '#control-view-template', + template: '#view-control-template', props: ['config', 'template', 'state'], @@ -51,10 +52,6 @@ module.exports = { return { mach_units: 'METRIC', mdi: '', - last_file: undefined, - last_file_time: undefined, - toolpath: {}, - toolpath_progress: 0, axes: 'xyzabc', history: [], speed_override: 1, @@ -65,16 +62,14 @@ module.exports = { axis_position: 0, jog_step: cookie.get_bool('jog-step'), jog_adjust: parseInt(cookie.get('jog-adjust', 2)), - deleteGCode: false, - tab: 'auto' + tab: 'auto', + highlighted_line: 0 } }, components: { - 'axis-control': require('./axis-control'), - 'path-viewer': require('./path-viewer'), - 'gcode-viewer': require('./gcode-viewer') + 'axis-control': require('./axis-control') }, @@ -94,18 +89,19 @@ module.exports = { 'state.line': function () { - if (this.mach_state != 'HOMING') - this.$broadcast('gcode-line', this.state.line); + if (this.mach_state != 'HOMING') this.highlight_gcode(); }, 'state.selected_time': function () {this.load()}, + 'state.queued_modified': function () {this.load(this.state.queued)}, jog_step: function () {cookie.set_bool('jog-step', this.jog_step)}, jog_adjust: function () {cookie.set('jog-adjust', this.jog_adjust)} }, computed: { + filename: function () {return util.display_path(this.state.queued)}, metric: function () {return !this.state.imperial}, @@ -165,27 +161,35 @@ module.exports = { }, + total_time: function () {return this.state.queued_time}, plan_time: function () {return this.state.plan_time}, - plan_time_remaining: function () { + remaining: function () { if (!(this.is_stopping || this.is_running || this.is_holding)) return 0; - return this.toolpath.time - this.plan_time + if (this.total_time < this.plan_time) return 0; + return this.total_time - this.plan_time }, eta: function () { if (this.mach_state != 'RUNNING') return ''; - var remaining = this.plan_time_remaining; var d = new Date(); - d.setSeconds(d.getSeconds() + remaining); + d.setSeconds(d.getSeconds() + this.remaining); return d.toLocaleString(); }, + simulating: function () { + return 0 < this.state.queued_progress && this.state.queued_progress < 1 + }, + + progress: function () { - if (!this.toolpath.time || this.is_ready) return 0; - var p = this.plan_time / this.toolpath.time; + if (this.simulating) return this.state.queued_progress; + + if (!this.total_time || this.is_ready) return 0; + var p = this.plan_time / this.total_time; return p < 1 ? p : 1; } }, @@ -205,52 +209,46 @@ module.exports = { }, - ready: function () {this.load()}, + ready: function () { + this.editor = CodeMirror.fromTextArea(this.$els.gcodeView, { + readOnly: true, + lineNumbers: true, + mode: 'gcode' + }) + this.editor.on('scrollCursorIntoView', this.on_scroll); - methods: { - send: function (msg) {this.$dispatch('send', msg)}, - - - load: function () { - var file_time = this.state.selected_time; - var file = this.state.selected; - if (this.last_file == file && this.last_file_time == file_time) return; - this.last_file = file; - this.last_file_time = file_time; + this.load(this.state.queued) + }, - this.$broadcast('gcode-load', file); - this.$broadcast('gcode-line', this.state.line); - this.toolpath_progress = 0; - this.load_toolpath(file, file_time); - }, + methods: { + send: function (msg) {this.$dispatch('send', msg)}, + on_scroll: function (cm, e) {e.preventDefault()}, - load_toolpath: function (file, file_time) { - this.toolpath = {}; - if (!file) return; + highlight_gcode: function () { + if (typeof this.editor == 'undefined') return; + var line = this.state.line - 1; + var doc = this.editor.getDoc(); - api.get('path/' + file).done(function (toolpath) { - if (this.last_file_time != file_time) return; + doc.removeLineClass(this.highlighted_line, 'wrap', 'highlight'); - if (typeof toolpath.progress == 'undefined') { - toolpath.filename = file; - this.toolpath_progress = 1; - this.toolpath = toolpath; + if (0 <= line) { + doc.addLineClass(line, 'wrap', 'highlight'); + this.highlighted_line = line; + this.editor.scrollIntoView({line: line, ch: 0}, 200); + } + }, - var state = this.$root.state; - var bounds = toolpath.bounds; - for (var axis of 'xyzabc') { - Vue.set(state, 'path_min_' + axis, bounds.min[axis]); - Vue.set(state, 'path_max_' + axis, bounds.max[axis]); - } - } else { - this.toolpath_progress = toolpath.progress; - this.load_toolpath(file, file_time); // Try again - } - }.bind(this)); + load: function (path) { + api.download('fs/' + path) + .done(function (data) { + if (this.state.queued != path) return; + this.editor.setValue(data); + this.highlight_gcode(); + }.bind(this)) }, @@ -275,47 +273,6 @@ module.exports = { load_history: function (index) {this.mdi = this.history[index];}, - open: function (e) { - // If we don't reset the form the browser may cache file if name is same - // even if contents have changed - $('.gcode-file-input')[0].reset(); - $('.gcode-file-input input').click(); - }, - - - upload: function (e) { - var files = e.target.files || e.dataTransfer.files; - if (!files.length) return; - - var file = files[0]; - var fd = new FormData(); - - fd.append('gcode', file); - - api.upload('file', fd) - .done(function () { - this.last_file_time = undefined; // Force reload - this.$broadcast('gcode-reload', file.name); - - }.bind(this)).fail(function (error) { - api.alert('Upload failed', error) - }.bind(this)); - }, - - - delete_current: function () { - if (this.state.selected) - api.delete('file/' + this.state.selected); - this.deleteGCode = false; - }, - - - delete_all: function () { - api.delete('file'); - this.deleteGCode = false; - }, - - home: function (axis) { if (typeof axis == 'undefined') api.put('home'); @@ -380,6 +337,20 @@ module.exports = { step: function () {api.put('step')}, + open: function () { + var path = this.state.queued; + + this.$root.file_dialog({ + callback: function (path) {if (path) api.put('queue/' + path)}, + dir: path ? util.dirname(path) : '/' + }) + }, + + + edit: function () {this.$root.edit(this.state.queued)}, + view: function () {this.$root.view(this.state.queued)}, + + override_feed: function () {api.put('override/feed/' + this.feed_override)}, diff --git a/src/js/view-editor.js b/src/js/view-editor.js new file mode 100644 index 0000000..9ff4de4 --- /dev/null +++ b/src/js/view-editor.js @@ -0,0 +1,251 @@ +/******************************************************************************\ + + This file is part of the Buildbotics firmware. + + Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. + + This Source describes Open Hardware and is licensed under the + CERN-OHL-S v2. + + You may redistribute and modify this Source and make products + using it under the terms of the CERN-OHL-S v2 (https:/cern.ch/cern-ohl). + This Source is distributed WITHOUT ANY EXPRESS OR IMPLIED + WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY AND FITNESS + FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-S v2 for applicable + conditions. + + Source location: https://github.com/buildbotics + + As per CERN-OHL-S v2 section 4, should You produce hardware based on + these sources, You must maintain the Source Location clearly visible on + the external case of the CNC Controller or other product you make using + this Source. + + For more information, email info@buildbotics.com + +\******************************************************************************/ + +'use strict'; + +var api = require('./api'); +var util = require('./util'); +var cookie = require('./cookie'); + + +module.exports = { + template: '#view-editor-template', + props: ['config', 'template', 'state'], + + + data: function () { + return { + loading: false, + path: undefined, + dirty: false, + canRedo: false, + canUndo: false, + clipboard: '' + } + }, + + + computed: { + filename: function () { + return (this.dirty ? '* ' : '') + + (util.display_path(this.path) || '(unnamed)') + }, + + + basename: function () { + return this.path ? util.basename(this.path) : 'unnamed.txt'; + } + }, + + + watch: { + 'state.queued': function () { + if (!this.path && this.state.queued) this.load(this.state.queued); + } + }, + + + attached: function (done) { + if (typeof this.doc == 'undefined') return; + this.load(cookie.get('selected-path')); + }, + + + ready: function () { + this.editor = CodeMirror.fromTextArea(this.$els.textarea, { + lineNumbers: true, + mode: 'gcode' + }); + this.doc = this.editor.getDoc(); + this.doc.on('change', this.change); + + var path = cookie.get('selected-path'); + if (!path) path = this.state.queued; + this.load(path); + }, + + + methods: { + change: function () { + this.dirty = !this.doc.isClean() + + var size = this.doc.historySize(); + this.canRedo = !!size.redo; + this.canUndo = !!size.undo; + }, + + + do_load: function (path) { + if (!path) this.set_path(); + else { + this.loading = true; + + api.download('fs/' + path) + .done(function (data) { + this.set_path(path); + this.set(data); + this.loading = false; + + }.bind(this)).fail(function (text, xhr, status) { + this.loading = false; + this.$root.error_dialog('Failed to open ' + path + ''); + if (cookie.get('selected-path') == path) + cookie.set('selected-path', ''); + }.bind(this)) + } + }, + + + load: function (path) { + if (this.path == path) return; + this.check_save(function () {this.do_load(path)}.bind(this)); + }, + + + set_path: function (path) { + this.path = path; + cookie.set('selected-path', path || ''); + }, + + + set: function (text) { + this.doc.setValue(text); + this.doc.clearHistory(); + this.doc.markClean(); + this.dirty = false; + this.canRedo = false; + this.canUndo = false; + }, + + + check_save: function (ok) { + if (!this.dirty) ok(); + else this.$root.open_dialog({ + title: 'Save file?', + body: 'The current file has been modified. ' + + 'Would you like to save it first?', + buttons: 'Cancel No Yes', + callback: { + yes: function () {this.save(ok)}.bind(this), + no: ok + } + }) + }, + + + new_file: function () { + this.check_save(function () { + this.set_path(); + this.set(''); + }.bind(this)); + }, + + + open: function () { + this.check_save(function () { + this.$root.file_dialog({ + callback: function (path) { + if (path) this.load(path) + }.bind(this), + dir: this.path ? util.dirname(this.path) : '/' + }) + }.bind(this)) + }, + + + do_save: function (path, ok) { + var fd = new FormData(); + var file = new File([new Blob([this.doc.getValue()])], path); + fd.append('file', file); + + api.upload('fs/' + path, fd) + .done(function () { + this.set_path(path); + this.dirty = false; + this.doc.markClean(); + if (typeof ok != 'undefined') ok() + + }.bind(this)).fail(function (error) { + this.$root.error_dialog({body: 'Save failed'}) + }.bind(this)); + }, + + + save: function (ok) { + if (!this.path) this.save_as(ok); + else this.do_save(this.path, ok); + }, + + + save_as: function (ok) { + this.$root.file_dialog({ + save: true, + callback: function (path) { + if (path) this.do_save(path, ok); + }.bind(this), + dir: this.path ? util.dirname(this.path) : '/' + }) + }, + + + revert: function () { + if (this.dirty) { + var path = this.path; + this.path = undefined; + this.dirty = false; + this.load(path) + } + }, + + + download: function () { + var data = new Blob([this.doc.getValue()], {type: 'text/plain'}); + window.URL.revokeObjectURL(this.$els.download.href); + this.$els.download.href = window.URL.createObjectURL(data); + this.$els.download.click(); + }, + + + view: function () { + this.check_save(function () {this.$root.view(this.path)}.bind(this)) + }, + + + undo: function () {this.doc.undo()}, + redo: function () {this.doc.redo()}, + + + cut: function () { + this.clipboard = this.doc.getSelection(); + this.doc.replaceSelection(''); + }, + + + copy: function () {this.clipboard = this.doc.getSelection()}, + paste: function () {this.doc.replaceSelection(this.clipboard)} + } +} diff --git a/src/js/view-files.js b/src/js/view-files.js new file mode 100644 index 0000000..6586851 --- /dev/null +++ b/src/js/view-files.js @@ -0,0 +1,99 @@ +/******************************************************************************\ + + This file is part of the Buildbotics firmware. + + Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. + + This Source describes Open Hardware and is licensed under the + CERN-OHL-S v2. + + You may redistribute and modify this Source and make products + using it under the terms of the CERN-OHL-S v2 (https:/cern.ch/cern-ohl). + This Source is distributed WITHOUT ANY EXPRESS OR IMPLIED + WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY AND FITNESS + FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-S v2 for applicable + conditions. + + Source location: https://github.com/buildbotics + + As per CERN-OHL-S v2 section 4, should You produce hardware based on + these sources, You must maintain the Source Location clearly visible on + the external case of the CNC Controller or other product you make using + this Source. + + For more information, email info@buildbotics.com + +\******************************************************************************/ + +'use strict' + + +var util = require('./util'); +var api = require('./api'); + + +module.exports = { + template: '#view-files-template', + props: ['state'], + + + data: function () { + return { + first: true, + selected: '', + is_dir: false + } + }, + + + attached: function () { + if (this.first) this.first = false; + else this.$refs.files.reload(); + }, + + + methods: { + upload: function () {this.$refs.files.upload()}, + new_folder: function () {this.$refs.files.new_folder()}, + + + set_selected: function (path, dir) { + this.selected = path + this.is_dir = dir; + }, + + + edit: function () { + if (this.selected && !this.is_dir) this.$root.edit(this.selected) + }, + + + view: function () { + if (this.selected && !this.is_dir) this.$root.view(this.selected) + }, + + + download: function () { + if (this.selected && !this.is_dir) this.$els.download.click(); + }, + + + delete: function () { + if (!this.selected) return; + + var filename = util.basename(this.selected); + + this.$root.open_dialog({ + title: 'Delete ' + (this.is_dir ? 'directory' : 'file') + '?', + body: 'Are you sure you want to delete ' + filename + + (this.is_dir ? ' and all the files under it?' : '?'), + buttons: 'Cancel OK', + callback: function (action) { + if (action == 'ok') + api.delete('fs/' + this.selected) + .done(this.$refs.files.reload) + }.bind(this) + }); + } + } +} diff --git a/src/js/view-settings.js b/src/js/view-settings.js new file mode 100644 index 0000000..3072793 --- /dev/null +++ b/src/js/view-settings.js @@ -0,0 +1,108 @@ +/******************************************************************************\ + + This file is part of the Buildbotics firmware. + + Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. + + This Source describes Open Hardware and is licensed under the + CERN-OHL-S v2. + + You may redistribute and modify this Source and make products + using it under the terms of the CERN-OHL-S v2 (https:/cern.ch/cern-ohl). + This Source is distributed WITHOUT ANY EXPRESS OR IMPLIED + WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY AND FITNESS + FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-S v2 for applicable + conditions. + + Source location: https://github.com/buildbotics + + As per CERN-OHL-S v2 section 4, should You produce hardware based on + these sources, You must maintain the Source Location clearly visible on + the external case of the CNC Controller or other product you make using + this Source. + + For more information, email info@buildbotics.com + +\******************************************************************************/ + +'use strict' + + +var api = require('./api'); + + +module.exports = { + template: '#view-settings-template', + props: ['config', 'template', 'state'], + + + data: function () { + return { + index: -1, + view: undefined, + modified: false + } + }, + + + components: { + 'settings-not-found': {template: '

    Settings page not found

    '}, + 'settings-general': require('./settings-general'), + 'settings-motor': require('./settings-motor'), + 'settings-tool': require('./settings-tool'), + 'settings-io': require('./settings-io'), + 'settings-network': require('./settings-network'), + 'settings-admin': require('./settings-admin') + }, + + + events: { + 'config-changed': function () { + this.modified = true; + return false; + }, + + + 'input-changed': function() { + this.$dispatch('config-changed'); + return false; + } + }, + + + ready: function () { + $(window).on('hashchange', this.parse_hash); + this.parse_hash(); + }, + + + methods: { + parse_hash: function () { + var hash = location.hash.substr(1); + + if (!hash.trim().length) { + location.hash = 'settings:general'; + return; + } + + var parts = hash.split(':'); + var view = parts.length == 1 ? 'general' : parts[1]; + + if (parts.length == 3) this.index = parts[2]; + + if (typeof this.$options.components['settings-' + view] == 'undefined') + this.view = 'not-found'; + + else this.view = view; + }, + + + save: function () { + api.put('config/save', this.config).done(function (data) { + this.modified = false; + }.bind(this)).fail(function (error) { + this.api_error('Save failed', error); + }.bind(this)); + } + } +} diff --git a/src/js/view-viewer.js b/src/js/view-viewer.js new file mode 100644 index 0000000..5253b1f --- /dev/null +++ b/src/js/view-viewer.js @@ -0,0 +1,143 @@ +/******************************************************************************\ + + This file is part of the Buildbotics firmware. + + Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. + + This Source describes Open Hardware and is licensed under the + CERN-OHL-S v2. + + You may redistribute and modify this Source and make products + using it under the terms of the CERN-OHL-S v2 (https:/cern.ch/cern-ohl). + This Source is distributed WITHOUT ANY EXPRESS OR IMPLIED + WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY AND FITNESS + FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-S v2 for applicable + conditions. + + Source location: https://github.com/buildbotics + + As per CERN-OHL-S v2 section 4, should You produce hardware based on + these sources, You must maintain the Source Location clearly visible on + the external case of the CNC Controller or other product you make using + this Source. + + For more information, email info@buildbotics.com + +\******************************************************************************/ + +'use strict' + +var api = require('./api'); +var cookie = require('./cookie'); +var util = require('./util'); + + +module.exports = { + template: '#view-viewer-template', + props: ['config', 'template', 'state'], + + + data: function () { + return { + loading: false, + retry: 0, + path: undefined, + toolpath: {}, + progress: 0, + snaps: 'angled top bottom front back left right'.split(' ') + } + }, + + + components: { + 'viewer-help-dialog': require('./viewer-help-dialog'), + 'path-viewer': require('./path-viewer') + }, + + + computed: { + filename: function () {return util.display_path(this.path)}, + + + show: function () { + if (this.$refs.viewer == undefined) return {}; + return this.$refs.viewer.show; + } + }, + + + watch: { + 'state.queued': function () { + if (!this.path && this.state.queued) this.load(this.state.queued); + } + }, + + + attached: function () { + var path = cookie.get('selected-path'); + if (!path) path = this.state.queued; + this.load(path) + }, + + + methods: { + _load: function (path) { + this.loading = true; + + api.get('path/' + path).done(function (toolpath) { + if (path != this.path) return; + this.retry = 0; + + if (toolpath.progress == undefined) { + toolpath.path = path; + this.progress = 1; + this.toolpath = toolpath; + this.loading = false; + + } else { + this._load(path); // Try again + this.progress = toolpath.progress; + } + + }.bind(this)).fail(function (error, xhr) { + if (xhr.status == 404) { + this.loading = false; + this.$root.api_error('', error); + return + } + + if (++this.retry < 10) + setTimeout(function () {this._load(path)}.bind(this), 5000); + else { + this.loading = false; + this.$root.api_error('3D view loading failed', error); + } + }.bind(this)) + }, + + + load: function(path) { + if (!path || this.path == path) return; + + cookie.set('selected-path', path) + this.path = path; + this.progress = 0; + this.toolpath = {}; + this.$refs.viewer.clear(); + + if (path) this._load(path); + }, + + + open: function () { + this.$root.file_dialog({ + callback: function (path) {this.load(path)}.bind(this), + dir: util.dirname(this.path) + }) + }, + + + toggle: function (name) {this.$refs.viewer.toggle(name)}, + snap: function (view) {this.$refs.viewer.snap(view)} + } +} diff --git a/src/js/settings-view.js b/src/js/viewer-help-dialog.js similarity index 88% rename from src/js/settings-view.js rename to src/js/viewer-help-dialog.js index dd99124..2d6259f 100644 --- a/src/js/settings-view.js +++ b/src/js/viewer-help-dialog.js @@ -29,14 +29,17 @@ module.exports = { - template: '#settings-view-template', - props: ['config', 'template'], + template: '#viewer-help-dialog-template', - events: { - 'input-changed': function() { - this.$dispatch('config-changed'); - return false; + data: function () { + return { + show: false } + }, + + + methods: { + open: function () {this.show = true} } } diff --git a/src/pug/index.pug b/src/pug/index.pug index 3eff62f..d84702e 100644 --- a/src/pug/index.pug +++ b/src/pug/index.pug @@ -38,48 +38,36 @@ html(lang="en") style: include ../static/css/font-awesome.min.css style: include ../static/css/Audiowide.css - style: include ../static/css/clusterize.css + style: include ../static/css/codemirror.css style: include:stylus ../stylus/style.styl body(v-cloak) #overlay(v-if="status != 'connected'") span {{status}} - #layout + #layout(:class="'view-' + currentView + '-page'") a#menuLink.menu-link(href="#menu"): span #menu - button.save.pure-button.button-success(:disabled="!modified", - @click="save") Save - .pure-menu ul.pure-menu-list li.pure-menu-heading a.pure-menu-link(href="#control") Control li.pure-menu-heading - a.pure-menu-link(href="#settings") Settings + a.pure-menu-link(href="#viewer") 3D View li.pure-menu-heading - a.pure-menu-link(href="#motor:0") Motors - - li.pure-menu-item(v-for="motor in config.motors") - a.pure-menu-link(:href="'#motor:' + $index") Motor {{$index}} + a.pure-menu-link(href="#editor") Editor li.pure-menu-heading - a.pure-menu-link(href="#tool") Tool + a.pure-menu-link(href="#camera") Camera li.pure-menu-heading - a.pure-menu-link(href="#io") I/O + a.pure-menu-link(href="#files") Files li.pure-menu-heading - a.pure-menu-link(href="#admin-general") Admin - - li.pure-menu-item - a.pure-menu-link(href="#admin-general") General - - li.pure-menu-item - a.pure-menu-link(href="#admin-network") Network + a.pure-menu-link(href="#settings") Settings li.pure-menu-heading a.pure-menu-link(href="#cheat-sheet") Cheat Sheet @@ -104,33 +92,25 @@ html(lang="en") .subtitle | CNC Controller #[b {{state.demo ? 'Demo ' : ''}}] | v{{config.version}} - a.upgrade-version(v-if="show_upgrade()", href="#admin-general") + a.upgrade-version(v-if="show_upgrade", href="#settings:admin") | Upgrade to v{{latestVersion}} - .fa.fa-check(v-if="!show_upgrade() && latestVersion", + .fa.fa-check(v-if="!show_upgrade && latestVersion", title="Firmware up to date") .copyright Copyright © 2015 - 2020, Buildbotics LLC - .estop(:class="{active: state.es}") - estop(@click="estop") - - .video(title="Plug camera into USB.\n" + - "Left click to toggle video size.\n" + - "Right click to toggle crosshair.", @click="toggle_video", - @contextmenu="toggle_crosshair", :class="video_size") - .crosshair(v-if="crosshair") - .vertical - .horizontal - .box - img(src="/api/video") + .header-tools + a(href="#camera"): video + .estop(:class="{active: state.es}"): estop(@click="estop") - .clear + .content(class="view-{{currentView}}") + component(:is="'view-' + currentView", :config="config", + :template="template", :state="state", keep-alive) - .content(class="{{currentView}}-view") - component(:is="currentView + '-view'", :index="index", - :config="config", :template="template", :state="state", keep-alive) + file-dialog(v-ref:file-dialog, :locations="state.locations") + dialog(v-ref:dialog) - message.error-message(:show.sync="errorShow") + message.error-message(:show.sync="showError") div(slot="header") .estop(:class="{active: state.es}"): estop(@click="estop") h3 ERROR: {{errorMessage}} @@ -146,46 +126,7 @@ html(lang="en") label seconds. div(slot="footer") - button.pure-button.pure-button-primary(@click="errorShow = false") Ok - - message(:show.sync="confirmUpgrade") - h3(slot="header") Upgrade Firmware? - div(slot="body") - p - | Are you sure you want to upgrade the firmware to version - | {{latestVersion}}? - - p.pure-control-group - label(for="pass") Password - input(name="pass", v-model="password", type="password", - @keyup.enter="upgrade_confirmed") - - div(slot="footer") - button.pure-button(@click="confirmUpgrade=false") Cancel - button.pure-button.pure-button-primary(@click="upgrade_confirmed") - | Upgrade - - message(:show.sync="confirmUpload") - h3(slot="header") Upload Firmware? - div(slot="body") - p Are you sure you want to upload firmware #[em {{firmwareName}}]? - - p.pure-control-group - label(for="pass") Password - input(name="pass", v-model="password", type="password", - @keyup.enter="upload_confirmed") - - div(slot="footer") - button.pure-button(@click="confirmUpload=false") Cancel - button.pure-button.pure-button-primary(@click="upload_confirmed") - | Upload - - message(:show.sync="firmwareUpgrading") - h3(slot="header") Firmware upgrading - div(slot="body") - h3 Please wait... - p Loss of power during an upgrade may damage the controller. - div(slot="footer") + button.pure-button.pure-button-primary(@click="showError = false") Ok message(v-if="popupMessages.length", :show="true") h3(slot="header") GCode message @@ -213,9 +154,8 @@ html(lang="en") script: include ../static/js/jquery-1.11.3.min.js script: include ../static/js/vue.js script: include ../static/js/sockjs.min.js - script: include ../static/js/clusterize.min.js script: include ../static/js/three.min.js script: include ../static/js/chart-2.9.3.min.js script: include ../static/js/chart.bundle-2.9.3.min.js script: include:browserify ../js/main.js - script: include ../static/js/ui.js + script: include ../static/js/codemirror.js diff --git a/src/pug/templates/dialog.pug b/src/pug/templates/dialog.pug new file mode 100644 index 0000000..eeb5684 --- /dev/null +++ b/src/pug/templates/dialog.pug @@ -0,0 +1,41 @@ +//-///////////////////////////////////////////////////////////////////////////// +//- // +//- This file is part of the Buildbotics firmware. // +//- // +//- Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. // +//- // +//- This Source describes Open Hardware and is licensed under the // +//- CERN-OHL-S v2. // +//- // +//- You may redistribute and modify this Source and make products // +//- using it under the terms of the CERN-OHL-S v2 (https:/cern.ch/cern-ohl). // +//- This Source is distributed WITHOUT ANY EXPRESS OR IMPLIED // +//- WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY AND FITNESS // +//- FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-S v2 for applicable // +//- conditions. // +//- // +//- Source location: https://github.com/buildbotics // +//- // +//- As per CERN-OHL-S v2 section 4, should You produce hardware based on // +//- these sources, You must maintain the Source Location clearly visible on // +//- the external case of the CNC Controller or other product you make using // +//- this Source. // +//- // +//- For more information, email info@buildbotics.com // +//- // +//-///////////////////////////////////////////////////////////////////////////// + +script#dialog-template(type="text/x-template") + .dialog + message(:show="show", click_away_close="false", @click-away="click_away") + h3(slot="header"). + #[.fa(v-if="config.icon", class="'fa-' + config.icon")] + {{config.title || ''}} + + div(slot="body") {{{config.body || ''}}} + + div(slot="footer") + button.pure-button(v-for="button in buttons", + @click="close(button.action)"). + #[.fa(v-if="button.icon", :class="'fa-' + button.icon")] + {{button.text}} diff --git a/src/pug/templates/file-dialog.pug b/src/pug/templates/file-dialog.pug new file mode 100644 index 0000000..48b3fbb --- /dev/null +++ b/src/pug/templates/file-dialog.pug @@ -0,0 +1,42 @@ +//-///////////////////////////////////////////////////////////////////////////// +//- // +//- This file is part of the Buildbotics firmware. // +//- // +//- Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. // +//- // +//- This Source describes Open Hardware and is licensed under the // +//- CERN-OHL-S v2. // +//- // +//- You may redistribute and modify this Source and make products // +//- using it under the terms of the CERN-OHL-S v2 (https:/cern.ch/cern-ohl). // +//- This Source is distributed WITHOUT ANY EXPRESS OR IMPLIED // +//- WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY AND FITNESS // +//- FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-S v2 for applicable // +//- conditions. // +//- // +//- Source location: https://github.com/buildbotics // +//- // +//- As per CERN-OHL-S v2 section 4, should You produce hardware based on // +//- these sources, You must maintain the Source Location clearly visible on // +//- the external case of the CNC Controller or other product you make using // +//- this Source. // +//- // +//- For more information, email info@buildbotics.com // +//- // +//-///////////////////////////////////////////////////////////////////////////// + +script#file-dialog-template(type="text/x-template") + .file-dialog + message(:show="show", click_away_close="false") + h3(slot="header") {{config.save ? 'Save' : 'Open'}} file + + div(slot="body") + files(:mode="config.save ? 'save' : 'open'", :locations="locations", + @selected="set_selected", @activate="response", v-ref:files) + + div(slot="footer") + button.pure-button(@click="cancel") #[.fa.fa-times] Cancel + button.pure-button.pure-button-primary(@click="ok", + :disabled="!selected"). + #[.fa(:class="'fa-' + (config.save ? 'save' : 'check')")] + {{config.save ? 'Save' : 'Open'}} diff --git a/src/pug/templates/files.pug b/src/pug/templates/files.pug new file mode 100644 index 0000000..d25f506 --- /dev/null +++ b/src/pug/templates/files.pug @@ -0,0 +1,90 @@ +//-///////////////////////////////////////////////////////////////////////////// +//- // +//- This file is part of the Buildbotics firmware. // +//- // +//- Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. // +//- // +//- This Source describes Open Hardware and is licensed under the // +//- CERN-OHL-S v2. // +//- // +//- You may redistribute and modify this Source and make products // +//- using it under the terms of the CERN-OHL-S v2 (https:/cern.ch/cern-ohl). // +//- This Source is distributed WITHOUT ANY EXPRESS OR IMPLIED // +//- WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY AND FITNESS // +//- FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-S v2 for applicable // +//- conditions. // +//- // +//- Source location: https://github.com/buildbotics // +//- // +//- As per CERN-OHL-S v2 section 4, should You produce hardware based on // +//- these sources, You must maintain the Source Location clearly visible on // +//- the external case of the CNC Controller or other product you make using // +//- this Source. // +//- // +//- For more information, email info@buildbotics.com // +//- // +//-///////////////////////////////////////////////////////////////////////////// + +script#files-template(type="text/x-template") + .files(v-if="fs.files") + .files-name(v-if="mode == 'save'") + label Name: + input(v-model="filename", @input="filename_changed") + + .files-body + .files-locations + .files-location.files-upload(v-if="mode != 'save'", + title="Upload a file from this computer to the controller.", + @click="upload") #[.fa.fa-upload] Upload + + form.file-upload(v-el:upload-form) + input(type="file", v-el:upload-form-input @change="do_upload") + + .files-location(v-for="name in locations", @click="open(name)", + :class="{active: name == location}", :title="location_title(name)") + .fa.fa-home(v-if="name == 'Home'") + .fa.fa-eject(v-else, @click.stop="eject(name)", + title="Eject USB drive") + | {{name}} + + .files-box + .files-path-bar + .files-path + button.pure-button(v-for="path in paths", track-by="$index", + :disabled="$index == paths.length - 1", + :title="path_title($index)", @click="load_path($index)") + | {{path}} + + .new-folder + button.pure-button(title="Create a new folder.", + @click="new_folder", :disabled="10 < paths.length"). + #[.fa.fa-plus] New Folder + + message(:show.sync="showNewFolder") + h3(slot="header") Folder Name + p(slot="body") + input(v-model="folder", @keyup.enter="create_folder") + div(slot="footer") + button.pure-button(@click="showNewFolder = false") + | #[.fa.fa-times] Cancel + button.pure-button.pure-button-primary( + :disabled="!folder_valid", @click="create_folder") + | #[.fa.fa-plus] Create + + .files-list + table + thead + tr + th.name Name + th.size Size + th.modified Modified + + tbody + tr(v-for="file in files", + :class="{selected: $index == selected}", + @click="select($index)", @dblclick="activate(file)") + td.name. + #[.fa(:class="file.dir ? 'fa-folder' : 'fa-file-o'")] + {{file.name}} + td.size: span(v-if="!file.dir") {{file.size | size}} + td.modified {{file.modified | ago}} diff --git a/src/pug/templates/loading-message.pug b/src/pug/templates/loading-message.pug new file mode 100644 index 0000000..ff3cd22 --- /dev/null +++ b/src/pug/templates/loading-message.pug @@ -0,0 +1,35 @@ +//-///////////////////////////////////////////////////////////////////////////// +//- // +//- This file is part of the Buildbotics firmware. // +//- // +//- Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. // +//- // +//- This Source describes Open Hardware and is licensed under the // +//- CERN-OHL-S v2. // +//- // +//- You may redistribute and modify this Source and make products // +//- using it under the terms of the CERN-OHL-S v2 (https:/cern.ch/cern-ohl). // +//- This Source is distributed WITHOUT ANY EXPRESS OR IMPLIED // +//- WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY AND FITNESS // +//- FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-S v2 for applicable // +//- conditions. // +//- // +//- Source location: https://github.com/buildbotics // +//- // +//- As per CERN-OHL-S v2 section 4, should You produce hardware based on // +//- these sources, You must maintain the Source Location clearly visible on // +//- the external case of the CNC Controller or other product you make using // +//- this Source. // +//- // +//- For more information, email info@buildbotics.com // +//- // +//-///////////////////////////////////////////////////////////////////////////// + +script#loading-message-template(type="text/x-template") + .loading-message + slot(name="header"): h3 Loading... + slot(name="body"): p Please wait. + + .progress(v-if="progress != undefined") + label {{progress | percent}} + .bar(:style="'width:' + (progress * 100) + '%'") diff --git a/src/pug/templates/message.pug b/src/pug/templates/message.pug index 8ce5dc8..18ad84b 100644 --- a/src/pug/templates/message.pug +++ b/src/pug/templates/message.pug @@ -27,8 +27,8 @@ script#message-template(type="text/x-template") .modal-mask(v-show="show", transition="modal") - .modal-wrapper - .modal-container + .modal-wrapper(@click="$emit('click-away')", v-el:wrapper) + .modal-container(@click.stop="") .modal-header slot(name="header") default header @@ -37,4 +37,4 @@ script#message-template(type="text/x-template") .modal-footer slot(name="footer") - button.pure-button.button-success(@click="show = false") OK + div: button.pure-button(@click="show = false") OK diff --git a/src/pug/templates/modbus-reg-view.pug b/src/pug/templates/modbus-reg.pug similarity index 98% rename from src/pug/templates/modbus-reg-view.pug rename to src/pug/templates/modbus-reg.pug index 1091d1a..f121e7d 100644 --- a/src/pug/templates/modbus-reg-view.pug +++ b/src/pug/templates/modbus-reg.pug @@ -25,7 +25,7 @@ //- // //-///////////////////////////////////////////////////////////////////////////// -script#modbus-reg-view-template(type="text/x-template") +script#modbus-reg-template(type="text/x-template") tr.modbus-reg td.reg-index {{index}} td.reg-type diff --git a/src/pug/templates/path-viewer.pug b/src/pug/templates/path-viewer.pug index 8d169f9..56f5037 100644 --- a/src/pug/templates/path-viewer.pug +++ b/src/pug/templates/path-viewer.pug @@ -26,32 +26,7 @@ //-///////////////////////////////////////////////////////////////////////////// script#path-viewer-template(type="text/x-template") - .path-viewer(v-show="enabled", :class="{small: small}") - .path-viewer-toolbar - .tool-button(title="Toggle path view size.", - @click="small = !small", :class="{active: !small}") - .fa.fa-arrows-alt - - .tool-button(@click="showTool = !showTool", :class="{active: showTool}", - title="Show/hide tool.") - img(src="images/tool.png") - - .tool-button(@click="showBBox = !showBBox", :class="{active: showBBox}", - title="Show/hide bounding box.") - img(src="images/bbox.png") - - .tool-button(@click="showAxes = !showAxes", :class="{active: showAxes}", - title="Show/hide axes.") - img(src="images/axes.png") - - .tool-button(@click="showIntensity = !showIntensity", - :class="{active: showIntensity}", title="Show/hide LASER intensity.") - img(src="images/intensity.png") - - each view in "isometric top front".split(" ") - .tool-button(@click=`snap('${view}')`, title=`Snap to ${view} view.`) - img(src=`images/${view}.png`) - + .path-viewer(v-show="enabled") .path-viewer-content table.path-viewer-messages( diff --git a/src/pug/templates/admin-general-view.pug b/src/pug/templates/settings-admin.pug similarity index 67% rename from src/pug/templates/admin-general-view.pug rename to src/pug/templates/settings-admin.pug index 1bff7e0..7e54174 100644 --- a/src/pug/templates/admin-general-view.pug +++ b/src/pug/templates/settings-admin.pug @@ -25,8 +25,19 @@ //- // //-///////////////////////////////////////////////////////////////////////////// -script#admin-general-view-template(type="text/x-template") - #admin-general +script#settings-admin-template(type="text/x-template") + #settings-admin + h1 Admin + + h2 Configuration + button.pure-button.pure-button-primary(@click="backup") Backup + + label.pure-button.pure-button-primary(@click="restore_config") Restore + form.restore-config.file-upload + input(type="file", accept=".json", @change="restore") + + button.pure-button.pure-button-primary(@click="reset") Reset + h2 Firmware button.pure-button.pure-button-primary(@click="check") Check button.pure-button.pure-button-primary(@click="upgrade") Upgrade @@ -39,30 +50,48 @@ script#admin-general-view-template(type="text/x-template") @change="change_auto_check_upgrade") label(for="auto-check-upgrade")   Automatically check for upgrades - h2 Configuration - button.pure-button.pure-button-primary(@click="backup") Backup - - label.pure-button.pure-button-primary(@click="restore_config") Restore - form.restore-config.file-upload - input(type="file", accept=".json", @change="restore") - message(:show.sync="configRestored") - h3(slot="header") Success - p(slot="body") Configuration restored. - - button.pure-button.pure-button-primary(@click="confirmReset = true") Reset - message(:show.sync="confirmReset") - h3(slot="header") Reset to default configuration? - p(slot="body") Non-network configuration changes will be lost. - div(slot="footer") - button.pure-button(@click="confirmReset = false") Cancel - button.pure-button.button-success(@click="reset") OK - - message(:show.sync="configReset") - h3(slot="header") Success - p(slot="body") Configuration reset. - h2 Debugging a(href="/api/log", target="_blank") button.pure-button.pure-button-primary View Log a(href="/api/bugreport", download) button.pure-button.pure-button-primary Bug Report + + + message(:show.sync="show.upgrade") + h3(slot="header") Upgrade Firmware? + div(slot="body") + p + | Are you sure you want to upgrade the firmware to version + | {{latestVersion}}? + + p.pure-control-group + label(for="pass") Password + input(name="pass", v-model="password", type="password", + @keyup.enter="upgrade_confirmed") + + div(slot="footer") + button.pure-button(@click="show.upgrade = false") Cancel + button.pure-button.pure-button-primary(@click="upgrade_confirmed") + | Upgrade + + message(:show.sync="show.upload") + h3(slot="header") Upload Firmware + div(slot="body") + p Enter password to upload firmware #[em {{firmwareName}}]? + + p.pure-control-group + label(for="pass") Password + input(name="pass", v-model="password", type="password", + @keyup.enter="upload_confirmed") + + div(slot="footer") + button.pure-button(@click="show.upload = false") Cancel + button.pure-button.pure-button-primary(@click="upload_confirmed") + | Upload + + message(:show.sync="show.upgrading", click_away_close="false") + h3(slot="header") Firmware upgrading + div(slot="body") + h3 Please wait... + p Loss of power during an upgrade may damage the controller. + div(slot="footer") diff --git a/src/pug/templates/settings-view.pug b/src/pug/templates/settings-general.pug similarity index 98% rename from src/pug/templates/settings-view.pug rename to src/pug/templates/settings-general.pug index 7d806ba..cbe58ce 100644 --- a/src/pug/templates/settings-view.pug +++ b/src/pug/templates/settings-general.pug @@ -47,9 +47,9 @@ //- // //-///////////////////////////////////////////////////////////////////////////// -script#settings-view-template(type="text/x-template") - #settings - h1 Settings +script#settings-general-template(type="text/x-template") + #settings-general + h1 General Configuration .pure-form.pure-form-aligned fieldset diff --git a/src/pug/templates/io-view.pug b/src/pug/templates/settings-io.pug similarity index 98% rename from src/pug/templates/io-view.pug rename to src/pug/templates/settings-io.pug index 705aa2c..344f55e 100644 --- a/src/pug/templates/io-view.pug +++ b/src/pug/templates/settings-io.pug @@ -25,7 +25,7 @@ //- // //-///////////////////////////////////////////////////////////////////////////// -script#io-view-template(type="text/x-template") +script#settings-io-template(type="text/x-template") #io h1 I/O Configuration diff --git a/src/pug/templates/motor-view.pug b/src/pug/templates/settings-motor.pug similarity index 98% rename from src/pug/templates/motor-view.pug rename to src/pug/templates/settings-motor.pug index 320a7c5..b97450e 100644 --- a/src/pug/templates/motor-view.pug +++ b/src/pug/templates/settings-motor.pug @@ -25,7 +25,7 @@ //- // //-///////////////////////////////////////////////////////////////////////////// -script#motor-view-template(type="text/x-template") +script#settings-motor-template(type="text/x-template") .motor(:class="{slave: is_slave}") h1 Motor {{index}} Configuration diff --git a/src/pug/templates/admin-network-view.pug b/src/pug/templates/settings-network.pug similarity index 93% rename from src/pug/templates/admin-network-view.pug rename to src/pug/templates/settings-network.pug index de1e966..8c095b0 100644 --- a/src/pug/templates/admin-network-view.pug +++ b/src/pug/templates/settings-network.pug @@ -25,8 +25,9 @@ //- // //-///////////////////////////////////////////////////////////////////////////// -script#admin-network-view-template(type="text/x-template") - #admin-network +script#settings-network-template(type="text/x-template") + #settings-network + h1 Network Configuration h2 Hostname .pure-form.pure-form-aligned .pure-control-group @@ -61,14 +62,6 @@ script#admin-network-view-template(type="text/x-template") input(name="pass2", v-model="password2", type="password") button.pure-button.pure-button-primary(@click="set_password") Set - message(:show.sync="passwordSet") - h3(slot="header") Password Set - p(slot="body") - - message(:show.sync="usernameSet") - h3(slot="header") Username Set - p(slot="body") - h2 Wifi Setup .pure-form.pure-form-aligned .pure-control-group @@ -108,9 +101,9 @@ script#admin-network-view-template(type="text/x-template") message.wifi-confirm(:show.sync="wifiConfirm") h3(slot="header") Configure Wifi and reboot? div(slot="body") - p - | After configuring the Wifi settings the controller will - | automatically reboot. + p. + After configuring the Wifi settings the controller will automatically + reboot. table tr th Mode diff --git a/src/pug/templates/tool-view.pug b/src/pug/templates/settings-tool.pug similarity index 99% rename from src/pug/templates/tool-view.pug rename to src/pug/templates/settings-tool.pug index 2139237..6c69018 100644 --- a/src/pug/templates/tool-view.pug +++ b/src/pug/templates/settings-tool.pug @@ -25,7 +25,7 @@ //- // //-///////////////////////////////////////////////////////////////////////////// -script#tool-view-template(type="text/x-template") +script#settings-tool-template(type="text/x-template") #tool h1 Tool Configuration diff --git a/src/pug/templates/gcode-viewer.pug b/src/pug/templates/video.pug similarity index 88% rename from src/pug/templates/gcode-viewer.pug rename to src/pug/templates/video.pug index 271bb68..3d76433 100644 --- a/src/pug/templates/gcode-viewer.pug +++ b/src/pug/templates/video.pug @@ -25,8 +25,11 @@ //- // //-///////////////////////////////////////////////////////////////////////////// -script#gcode-viewer-template(type="text/x-template") - .gcode - .clusterize - .clusterize-scroll - ul.clusterize-content +script#video-template(type="text/x-template") + .video(title="Plug camera into USB.", v-el:video) + .video-content + .crosshair(v-if="$root.crosshair") + .vertical + .horizontal + .box + img(src="/api/video", v-el:img, @click="reload") diff --git a/src/pug/templates/view-camera.pug b/src/pug/templates/view-camera.pug new file mode 100644 index 0000000..2b8b784 --- /dev/null +++ b/src/pug/templates/view-camera.pug @@ -0,0 +1,39 @@ +//-///////////////////////////////////////////////////////////////////////////// +//- // +//- This file is part of the Buildbotics firmware. // +//- // +//- Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. // +//- // +//- This Source describes Open Hardware and is licensed under the // +//- CERN-OHL-S v2. // +//- // +//- You may redistribute and modify this Source and make products // +//- using it under the terms of the CERN-OHL-S v2 (https:/cern.ch/cern-ohl). // +//- This Source is distributed WITHOUT ANY EXPRESS OR IMPLIED // +//- WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY AND FITNESS // +//- FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-S v2 for applicable // +//- conditions. // +//- // +//- Source location: https://github.com/buildbotics // +//- // +//- As per CERN-OHL-S v2 section 4, should You produce hardware based on // +//- these sources, You must maintain the Source Location clearly visible on // +//- the external case of the CNC Controller or other product you make using // +//- this Source. // +//- // +//- For more information, email info@buildbotics.com // +//- // +//-///////////////////////////////////////////////////////////////////////////// + +script#view-camera-template(type="text/x-template") + div + nav.navbar + nav-item + | Settings + .fa.fa-caret-down + nav-menu + nav-item(@click="$root.crosshair = !$root.crosshair") + .fa(:class="$root.crosshair ? 'fa-check' : ''") + span Show Crosshair + + video diff --git a/src/pug/templates/cheat-sheet-view.pug b/src/pug/templates/view-cheat-sheet.pug similarity index 99% rename from src/pug/templates/cheat-sheet-view.pug rename to src/pug/templates/view-cheat-sheet.pug index 83ece9e..9f851a3 100644 --- a/src/pug/templates/cheat-sheet-view.pug +++ b/src/pug/templates/view-cheat-sheet.pug @@ -25,7 +25,7 @@ //- // //-///////////////////////////////////////////////////////////////////////////// -script#cheat-sheet-view-template(type="text/x-template") +script#view-cheat-sheet-template(type="text/x-template") // Modified from http://linuxcnc.org/docs/html/gcode.html - var base = 'http://linuxcnc.org/docs/html/gcode'; - var camotics_base = 'https://camotics.org/gcode.html'; @@ -397,7 +397,7 @@ script#cheat-sheet-view-template(type="text/x-template") td a(target="_blank", href=`${base}/g-code.html#gcode:g17-g19.1`) | G17 - G19.1 - td (affects G2, G3, G81…G89, G40…G42) + td td Plane Select tr.spacer-row: th @@ -458,7 +458,7 @@ script#cheat-sheet-view-template(type="text/x-template") td a(target="_blank", href=`${base}/m-code.html#mcode:m73`) M73 td - td Save and Auto-restore modal state + td Save/restore modal state tr.spacer-row.unimplemented(v-if="showUnimplemented"): th tr.header-row.unimplemented(v-if="showUnimplemented") diff --git a/src/pug/templates/control-view.pug b/src/pug/templates/view-control.pug similarity index 79% rename from src/pug/templates/control-view.pug rename to src/pug/templates/view-control.pug index 5355d21..f54c196 100644 --- a/src/pug/templates/control-view.pug +++ b/src/pug/templates/view-control.pug @@ -25,7 +25,7 @@ //- // //-///////////////////////////////////////////////////////////////////////////// -script#control-view-template(type="text/x-template") +script#view-control-template(type="text/x-template") #control table.axes tr(:class="axes.klass") @@ -123,10 +123,7 @@ script#control-view-template(type="text/x-template") tr(title="Active machine units") th Units - td.mach_units - select(v-model="mach_units", :disabled="!is_idle") - option(value="METRIC") METRIC - option(value="IMPERIAL") IMPERIAL + td.mach_units {{mach_units}} tr(title="Active tool") th Tool @@ -167,8 +164,8 @@ script#control-view-template(type="text/x-template") tr th Remaining td(title="Total run time (days:hours:mins:secs)"). - #[span(v-if="plan_time_remaining") {{plan_time_remaining | time}} of] - {{toolpath.time | time}} + #[span(v-if="remaining") {{remaining | time}} of] + {{total_time | time}} tr th ETA td.eta {{eta}} @@ -176,10 +173,10 @@ script#control-view-template(type="text/x-template") th Line td | {{0 <= state.line ? state.line : 0 | number}} - span(v-if="toolpath.lines") - |  of {{toolpath.lines | number}} + span(v-if="state.lines") + |  of {{state.lines | number}} tr - th Progress + th {{this.simulating ? 'Simulating' : 'Progress'}} td.progress label {{(progress || 0) | percent}} .bar(:style="'width:' + (progress || 0) * 100 + '%'") @@ -216,64 +213,38 @@ script#control-view-template(type="text/x-template") .toolbar.pure-control-group button.pure-button(:class="{'attention': is_holding}", title="{{is_running ? 'Pause' : 'Start'}} program.", - @click="start_pause", :disabled="!state.selected") - .fa(:class="is_running ? 'fa-pause' : 'fa-play'") + @click="start_pause", :disabled="!state.queued"). + #[.fa(:class="is_running ? 'fa-pause' : 'fa-play'")] + {{is_running ? 'Pause' : 'Run'}} + + button.pure-button(title="Stop program.", @click="stop"). + #[.fa.fa-stop] Stop - button.pure-button(title="Stop program.", @click="stop") - .fa.fa-stop button.pure-button(title="Pause program at next optional stop (M1).", - @click="optional_pause", v-if="false") - .fa.fa-stop-circle-o + @click="optional_pause", v-if="false"). + #[.fa.fa-stop-circle-o] Optional Pause button.pure-button(title="Execute one program step.", @click="step", - :disabled="(!is_ready && !is_holding) || !state.selected", - v-if="false") - .fa.fa-step-forward - - button.pure-button(title="Upload a new GCode program.", @click="open", - :disabled="!is_ready") - .fa.fa-folder-open - - form.gcode-file-input.file-upload - input(type="file", @change="upload", :disabled="!is_ready", - accept="text/*,.nc,.gcode,.gc,.ngc,.txt,.tap,.cnc") - - a.pure-button(:disabled="!state.selected", download, - :href="'/api/file/' + state.selected", - title="Download the selected GCode program.") - .fa.fa-download - - button.pure-button(title="Delete current GCode program.", - @click="deleteGCode = true", - :disabled="!state.selected || !is_ready") - .fa.fa-trash - - message(:show.sync="deleteGCode") - h3(slot="header") Delete GCode? - p(slot="body") - div(slot="footer") - button.pure-button(@click="deleteGCode = false") Cancel - button.pure-button.button-error(@click="delete_all") - .fa.fa-trash - |  all - button.pure-button.button-success(@click="delete_current") - .fa.fa-trash - |  selected - - select(title="Select previously uploaded GCode programs.", - v-model="state.selected", @change="load", :disabled="!is_ready") - option(v-for="file in state.files", :value="file") {{file}} - - .progress(v-if="toolpath_progress && toolpath_progress < 1", - title="Simulating GCode to check for errors, calculate ETA and " + - "generate 3D view. You can run GCode before the simulation " + - "finishes.") - div(:style="'width:' + (toolpath_progress || 0) * 100 + '%'") - label Simulating {{(toolpath_progress || 0) | percent}} - - path-viewer(:toolpath="toolpath", :state="state", :config="config") - gcode-viewer + :disabled="(!is_ready && !is_holding) || !state.queued", + v-if="false"). + #[.fa.fa-step-forward] Step + + button.pure-button(title="Select a program.", @click="open", + :disabled="!is_ready"). + #[.fa.fa-folder-open] Open + + button.pure-button(title="Edit program.", @click="edit", + :disabled="!state.queued"). + #[.fa.fa-pencil] Edit + + button.pure-button(title="Open 3D view.", @click="view", + :disabled="!state.queued"). + #[.fa.fa-eye] View + + .filename {{filename}} + + textarea.gcode-view(v-el:gcode-view) section#content2.tab-content .mdi.pure-form(title="Manual GCode entry.") diff --git a/src/pug/templates/view-editor.pug b/src/pug/templates/view-editor.pug new file mode 100644 index 0000000..e71cd55 --- /dev/null +++ b/src/pug/templates/view-editor.pug @@ -0,0 +1,101 @@ +//-///////////////////////////////////////////////////////////////////////////// +//- // +//- This file is part of the Buildbotics firmware. // +//- // +//- Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. // +//- // +//- This Source describes Open Hardware and is licensed under the // +//- CERN-OHL-S v2. // +//- // +//- You may redistribute and modify this Source and make products // +//- using it under the terms of the CERN-OHL-S v2 (https:/cern.ch/cern-ohl). // +//- This Source is distributed WITHOUT ANY EXPRESS OR IMPLIED // +//- WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY AND FITNESS // +//- FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-S v2 for applicable // +//- conditions. // +//- // +//- Source location: https://github.com/buildbotics // +//- // +//- As per CERN-OHL-S v2 section 4, should You produce hardware based on // +//- these sources, You must maintain the Source Location clearly visible on // +//- the external case of the CNC Controller or other product you make using // +//- this Source. // +//- // +//- For more information, email info@buildbotics.com // +//- // +//-///////////////////////////////////////////////////////////////////////////// + +script#view-editor-template(type="text/x-template") + div + nav.navbar + nav-item + | File + .fa.fa-caret-down + + nav-menu + nav-item(@click="new_file()") + .fa.fa-file + span New + + nav-item(@click="open()") + .fa.fa-folder-open + span Open + + nav-item(@click="save()", :disabled="!dirty") + .fa.fa-save + span Save + + nav-item(@click="save_as()") + .fa.fa-save + span Save As + + nav-item(@click="revert()", :disabled="!dirty") + .fa.fa-undo + span Revert + + nav-item(@click="download()") + .fa.fa-download + span Download + a(v-el:download, style="display:none", :download="basename") + + nav-item(@click="view()") + .fa.fa-eye + span View + + nav-item(@click="$root.run(path)", :disabled="state.xx != 'READY'") + .fa.fa-play + span Run + + nav-item + | Edit + .fa.fa-caret-down + nav-menu + nav-item(@click="undo()", :disabled="!canUndo") + .fa.fa-undo + span Undo + span Ctrl-Z + + nav-item(@click="redo()", :disabled="!canRedo") + .fa.fa-repeat + span Redo + span Ctrl-Y + + nav-item(@click="copy()") + .fa.fa-clone + span Copy + span Ctrl-C + + nav-item(@click="cut()") + .fa.fa-scissors + span Cut + span Ctrl-X + + nav-item(@click="paste()") + .fa.fa-clipboard + span Paste + span Ctrl-V + + .filename {{filename}} + + loading-message(v-if="loading") + textarea(v-el:textarea) diff --git a/src/pug/templates/view-files.pug b/src/pug/templates/view-files.pug new file mode 100644 index 0000000..8ed9d48 --- /dev/null +++ b/src/pug/templates/view-files.pug @@ -0,0 +1,66 @@ +//-///////////////////////////////////////////////////////////////////////////// +//- // +//- This file is part of the Buildbotics firmware. // +//- // +//- Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. // +//- // +//- This Source describes Open Hardware and is licensed under the // +//- CERN-OHL-S v2. // +//- // +//- You may redistribute and modify this Source and make products // +//- using it under the terms of the CERN-OHL-S v2 (https:/cern.ch/cern-ohl). // +//- This Source is distributed WITHOUT ANY EXPRESS OR IMPLIED // +//- WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY AND FITNESS // +//- FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-S v2 for applicable // +//- conditions. // +//- // +//- Source location: https://github.com/buildbotics // +//- // +//- As per CERN-OHL-S v2 section 4, should You produce hardware based on // +//- these sources, You must maintain the Source Location clearly visible on // +//- the external case of the CNC Controller or other product you make using // +//- this Source. // +//- // +//- For more information, email info@buildbotics.com // +//- // +//-///////////////////////////////////////////////////////////////////////////// + +script#view-files-template(type="text/x-template") + div + nav.navbar + nav-item + | File + .fa.fa-caret-down + nav-menu + nav-item(@click="upload") + .fa.fa-upload + span Upload File + + nav-item(@click="new_folder") + .fa.fa-plus + span New Folder + + nav-item + | Selection + .fa.fa-caret-down + nav-menu + nav-item(@click="edit", :disabled="!selected || is_dir") + .fa.fa-pencil + span Edit + + nav-item(@click="view", :disabled="!selected || is_dir") + .fa.fa-eye + span View + + nav-item(@click="download", :disabled="!selected || is_dir") + .fa.fa-download + span Download + + nav-item(@click="delete", :disabled="!selected") + .fa.fa-trash + span Delete + + a(v-el:download, :href="'/api/fs/' + selected", style="display:none", + download, target="_blank") + files(v-ref:files, @selected="set_selected", @activate="download", + :locations="state.locations") diff --git a/src/pug/templates/help-view.pug b/src/pug/templates/view-help.pug similarity index 98% rename from src/pug/templates/help-view.pug rename to src/pug/templates/view-help.pug index 22ec872..f6fae97 100644 --- a/src/pug/templates/help-view.pug +++ b/src/pug/templates/view-help.pug @@ -25,7 +25,7 @@ //- // //-///////////////////////////////////////////////////////////////////////////// -script#help-view-template(type="text/x-template") +script#view-help-template(type="text/x-template") #help h2 User Manual p diff --git a/src/pug/templates/license-view.pug b/src/pug/templates/view-license.pug similarity index 96% rename from src/pug/templates/license-view.pug rename to src/pug/templates/view-license.pug index 7b49ec2..6fd1acb 100644 --- a/src/pug/templates/license-view.pug +++ b/src/pug/templates/view-license.pug @@ -25,13 +25,13 @@ //- // //-///////////////////////////////////////////////////////////////////////////// -script#license-view-template(type="text/x-template") +script#view-license-template(type="text/x-template") #license h2 License p. This is Open Hardware licensed under the CERN-OHL-S v2. Unless - otherwise noted, all sources are copyright 2015 - 2020, + otherwise noted, all sources are copyright 2015 - 2021, Buildbotics LLC. p. diff --git a/src/pug/templates/view-settings.pug b/src/pug/templates/view-settings.pug new file mode 100644 index 0000000..98cdd53 --- /dev/null +++ b/src/pug/templates/view-settings.pug @@ -0,0 +1,72 @@ +//-///////////////////////////////////////////////////////////////////////////// +//- // +//- This file is part of the Buildbotics firmware. // +//- // +//- Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. // +//- // +//- This Source describes Open Hardware and is licensed under the // +//- CERN-OHL-S v2. // +//- // +//- You may redistribute and modify this Source and make products // +//- using it under the terms of the CERN-OHL-S v2 (https:/cern.ch/cern-ohl). // +//- This Source is distributed WITHOUT ANY EXPRESS OR IMPLIED // +//- WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY AND FITNESS // +//- FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-S v2 for applicable // +//- conditions. // +//- // +//- Source location: https://github.com/buildbotics // +//- // +//- As per CERN-OHL-S v2 section 4, should You produce hardware based on // +//- these sources, You must maintain the Source Location clearly visible on // +//- the external case of the CNC Controller or other product you make using // +//- this Source. // +//- // +//- For more information, email info@buildbotics.com // +//- // +//-///////////////////////////////////////////////////////////////////////////// + +//- All rights reserved. // +//- // +//- This file ("the software") is free software: you can redistribute it // +//- and/or modify it under the terms of the GNU General Public License, // +//- version 2 as published by the Free Software Foundation. You should // +//- have received a copy of the GNU General Public License, version 2 // +//- along with the software. If not, see . // +//- // +//- The software is distributed in the hope that it will be useful, but // +//- WITHOUT ANY WARRANTY; without even the implied warranty of // +//- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU // +//- Lesser General Public License for more details. // +//- // +//- You should have received a copy of the GNU Lesser General Public // +//- License along with the software. If not, see // +//- . // +//- // +//- For information regarding this software email: // +//- "Joseph Coffland" // +//- // +//-///////////////////////////////////////////////////////////////////////////// + +script#view-settings-template(type="text/x-template") + div + nav.navbar + a.nav-item(href="#settings:general") General + + nav-item + | Motors + .fa.fa-caret-down + + nav-menu + a.nav-item(v-for="motor in [0, 1, 2, 3]", + :href="'#settings:motor:' + motor") Motor {{motor}} + + a.nav-item(href="#settings:tool") Tool + a.nav-item(href="#settings:io") I/O + a.nav-item(href="#settings:network") Network + a.nav-item(href="#settings:admin") Admin + + button.save.pure-button.button-success(:disabled="!modified", + @click="save") Save + + .settings-view(v-if="view", :is="'settings-' + view", :index="index", + :config="config", :template="template", :state="state", keep-alive) diff --git a/src/pug/templates/view-viewer.pug b/src/pug/templates/view-viewer.pug new file mode 100644 index 0000000..eab578b --- /dev/null +++ b/src/pug/templates/view-viewer.pug @@ -0,0 +1,94 @@ +//-///////////////////////////////////////////////////////////////////////////// +//- // +//- This file is part of the Buildbotics firmware. // +//- // +//- Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. // +//- // +//- This Source describes Open Hardware and is licensed under the // +//- CERN-OHL-S v2. // +//- // +//- You may redistribute and modify this Source and make products // +//- using it under the terms of the CERN-OHL-S v2 (https:/cern.ch/cern-ohl). // +//- This Source is distributed WITHOUT ANY EXPRESS OR IMPLIED // +//- WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY AND FITNESS // +//- FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-S v2 for applicable // +//- conditions. // +//- // +//- Source location: https://github.com/buildbotics // +//- // +//- As per CERN-OHL-S v2 section 4, should You produce hardware based on // +//- these sources, You must maintain the Source Location clearly visible on // +//- the external case of the CNC Controller or other product you make using // +//- this Source. // +//- // +//- For more information, email info@buildbotics.com // +//- // +//-///////////////////////////////////////////////////////////////////////////// + +script#view-viewer-template(type="text/x-template") + #viewer + nav.navbar + nav-item + | File + .fa.fa-caret-down + + nav-menu + nav-item(@click="open()", title="Open a new program.") + .fa.fa-folder-open + span Open + + nav-item(@click="$root.edit(path)", + title="Open the current program in the editor.") + .fa.fa-pencil + span Edit + + nav-item(@click="$root.run(path)", :disabled="state.xx != 'READY'", + title="Start the current program.") + .fa.fa-play + span Run + + nav-item + | View + .fa.fa-caret-down + nav-menu + nav-item(@click="toggle('tool')") + .fa(:class="show.tool ? 'fa-check' : ''") + span Show Tool + + nav-item(@click="toggle('axes')") + .fa(:class="show.axes ? 'fa-check' : ''") + span Show Axes + + nav-item(@click="toggle('grid')") + .fa(:class="show.grid ? 'fa-check' : ''") + span Show Grid + + nav-item(@click="toggle('dims')") + .fa(:class="show.dims ? 'fa-check' : ''") + span Show Bounds + + nav-item(@click="toggle('intensity')") + .fa(:class="show.intensity ? 'fa-check' : ''") + span LASER Raster + + nav-item + | Snap + .fa.fa-caret-down + nav-menu + nav-item.snap(v-for="name in snaps" @click="snap(name)") + img(:src="'images/' + name + '.png'") + span {{name}} + + nav-item(@click="$refs.helpDialog.open()") Help + + .filename {{filename}} + + loading-message(v-if="loading", :progress="progress") + p(slot="body"). + Simulating program to check for errors, calculate timing and generate + 3D view. Please wait... + + viewer-help-dialog(v-ref:help-dialog) + + path-viewer(v-ref:viewer, :toolpath="toolpath", :state="state", + :config="config") diff --git a/src/pug/templates/viewer-help-dialog.pug b/src/pug/templates/viewer-help-dialog.pug new file mode 100644 index 0000000..661baf7 --- /dev/null +++ b/src/pug/templates/viewer-help-dialog.pug @@ -0,0 +1,65 @@ +//-///////////////////////////////////////////////////////////////////////////// +//- // +//- This file is part of the Buildbotics firmware. // +//- // +//- Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. // +//- // +//- This Source describes Open Hardware and is licensed under the // +//- CERN-OHL-S v2. // +//- // +//- You may redistribute and modify this Source and make products // +//- using it under the terms of the CERN-OHL-S v2 (https:/cern.ch/cern-ohl). // +//- This Source is distributed WITHOUT ANY EXPRESS OR IMPLIED // +//- WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY AND FITNESS // +//- FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-S v2 for applicable // +//- conditions. // +//- // +//- Source location: https://github.com/buildbotics // +//- // +//- As per CERN-OHL-S v2 section 4, should You produce hardware based on // +//- these sources, You must maintain the Source Location clearly visible on // +//- the external case of the CNC Controller or other product you make using // +//- this Source. // +//- // +//- For more information, email info@buildbotics.com // +//- // +//-///////////////////////////////////////////////////////////////////////////// + +script#viewer-help-dialog-template(type="text/x-template") + .viewer-help-dialog + message(:show.sync="show") + h3(slot="header") 3D Viewer Help + + div(slot="body") + h2 Mouse Controls + table + tr + th Left Mouse + td Hold and drag to rotate + tr + th Middle Mouse + td Hold and drag to zoom + tr + th Right Mouse + td Hold and drag to pan + tr + th Mouse Wheel + td Zoom in and out + + h2 Dimensions + p. + Dimensions are displayed in the currently selected units which + can be changed on the #[a(href="#settings:general") SETTINGS page]. + + h2 Grid + p. + The grid lies along on the X/Y plane. The spacing is 10mm when + metric units are selected and 1in for imperial units. + + h2 LASER Raster + p. + When enabled, the #[em LASER Raster] setting colors #[tt G0] moves + according to the current GCode #[tt S] value or speed setting. Many + LASERS use #[tt S] to set LASER intensity. This allows you to + visualize what a LASER rastering program will burn onto the + workpiece. diff --git a/src/py/bbctrl/Camera.py b/src/py/bbctrl/Camera.py index ceb8f3f..76cfb51 100644 --- a/src/py/bbctrl/Camera.py +++ b/src/py/bbctrl/Camera.py @@ -342,7 +342,7 @@ class Camera(object): except Exception as e: if isinstance(e, BlockingIOError): return - self.log.warning('Failed to read from camera.') + self.log.info('Failed to read from camera. Unplugged?') self.ioloop.remove_handler(fd) self.close() @@ -476,7 +476,7 @@ class VideoHandler(web.RequestHandler): self.set_header('Connection', 'close') self.set_header('Content-Type', 'multipart/x-mixed-replace;boundary=' + self.boundary) - self.set_header('Expires', 'Mon, 3 Jan 2000 12:34:56 GMT') + self.set_header('Expires', 'Tue, 01 Jan 1980 1:00:00 GMT') self.set_header('Pragma', 'no-cache') if self.camera is None: self.write_img('offline') diff --git a/src/py/bbctrl/Config.py b/src/py/bbctrl/Config.py index 0ff1019..35a447f 100644 --- a/src/py/bbctrl/Config.py +++ b/src/py/bbctrl/Config.py @@ -181,14 +181,14 @@ class Config(object): os.sync() - self.ctrl.preplanner.invalidate_all() + self.ctrl.events.emit('invalidate-all') self.log.info('Saved') def reset(self): if os.path.exists('config.json'): os.unlink('config.json') self.reload() - self.ctrl.preplanner.invalidate_all() + self.ctrl.events.emit('invalidate-all') def _encode(self, name, index, config, tmpl, with_defaults): diff --git a/src/py/bbctrl/Ctrl.py b/src/py/bbctrl/Ctrl.py index e643f79..5597610 100644 --- a/src/py/bbctrl/Ctrl.py +++ b/src/py/bbctrl/Ctrl.py @@ -37,14 +37,18 @@ class Ctrl(object): self.id = id self.timeout = None # Used in demo mode - if id and not os.path.exists(id): os.mkdir(id) + if id: + if not os.path.exists(id): os.mkdir(id) + self.root = './' + id + else: self.root = '.' # Start log if args.demo: log_path = self.get_path(filename = 'bbctrl.log') else: log_path = args.log self.log = bbctrl.log.Log(args, self.ioloop, log_path) - self.state = bbctrl.State(self) + self.events = bbctrl.Events(self) + self.state = bbctrl.State(self) self.config = bbctrl.Config(self) self.log.get('Ctrl').info('Starting %s' % self.id) @@ -57,6 +61,7 @@ class Ctrl(object): self.lcd = bbctrl.LCD(self) self.mach = bbctrl.Mach(self, self.avr) self.preplanner = bbctrl.Preplanner(self) + self.fs = bbctrl.FileSystem(self) if not args.demo: self.jog = bbctrl.Jog(self) self.pwr = bbctrl.Pwr(self) @@ -65,8 +70,6 @@ class Ctrl(object): self.lcd.add_new_page(bbctrl.MainLCDPage(self)) self.lcd.add_new_page(bbctrl.IPLCDPage(self.lcd)) - os.environ['GCODE_SCRIPT_PATH'] = self.get_upload() - except Exception: self.log.get('Ctrl').exception() @@ -85,15 +88,10 @@ class Ctrl(object): def get_path(self, dir = None, filename = None): - path = './' + self.id if self.id else '.' - path = path if dir is None else (path + '/' + dir) + path = self.root if dir is None else (self.root + '/' + dir) return path if filename is None else (path + '/' + filename) - def get_upload(self, filename = None): - return self.get_path('upload', filename) - - def get_plan(self, filename = None): return self.get_path('plans', filename) diff --git a/src/py/bbctrl/FileHandler.py b/src/py/bbctrl/Events.py similarity index 55% rename from src/py/bbctrl/FileHandler.py rename to src/py/bbctrl/Events.py index 22d3736..b9582c3 100644 --- a/src/py/bbctrl/FileHandler.py +++ b/src/py/bbctrl/Events.py @@ -25,61 +25,30 @@ # # ################################################################################ -import os import bbctrl -import glob -import html -from tornado import gen -from tornado.web import HTTPError -def safe_remove(path): - try: - os.unlink(path) - except OSError: pass +class Events(object): + def __init__(self, ctrl): + self.ctrl = ctrl + self.listeners = {} + self.log = ctrl.log.get('Events') -class FileHandler(bbctrl.APIHandler): - def prepare(self): pass + def on(self, event, listener): + if not event in self.listeners: self.listeners[event] = [] + self.listeners[event].append(listener) - def delete_ok(self, filename): - if not filename: - # Delete everything - for path in glob.glob(self.get_upload('*')): safe_remove(path) - self.get_ctrl().preplanner.delete_all_plans() - self.get_ctrl().state.clear_files() + def off(self, event, listener): + if event in self.listeners: + self.listeners[event].remove(listener) - else: - # Delete a single file - filename = os.path.basename(filename) - safe_remove(self.get_upload(filename)) - self.get_ctrl().preplanner.delete_plans(filename) - self.get_ctrl().state.remove_file(filename) - - def put_ok(self, *args): - gcode = self.request.files['gcode'][0] - filename = os.path.basename(gcode['filename'].replace('\\', '/')) - filename = filename.replace('#', '-').replace('?', '-') - - if not os.path.exists(self.get_upload()): os.mkdir(self.get_upload()) - - with open(self.get_upload(filename).encode('utf8'), 'wb') as f: - f.write(gcode['body']) - os.sync() - - self.get_ctrl().preplanner.invalidate(filename) - self.get_ctrl().state.add_file(filename) - self.get_log('FileHandler').info('GCode received: ' + filename) - - - @gen.coroutine - def get(self, filename): - if not filename: raise HTTPError(400, 'Missing filename') - filename = os.path.basename(filename) - - with open(self.get_upload(filename).encode('utf8'), 'r') as f: - self.write(f.read()) - - self.get_ctrl().state.select_file(filename) + def emit(self, event, *args, **kwargs): + if event in self.listeners: + for listener in self.listeners[event]: + try: + listener(*args, **kwargs) + except Exception as e: + self.log.exception() diff --git a/src/py/bbctrl/FileSystem.py b/src/py/bbctrl/FileSystem.py new file mode 100644 index 0000000..4f1ef0f --- /dev/null +++ b/src/py/bbctrl/FileSystem.py @@ -0,0 +1,190 @@ +################################################################################ +# # +# This file is part of the Buildbotics firmware. # +# # +# Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. # +# # +# This Source describes Open Hardware and is licensed under the # +# CERN-OHL-S v2. # +# # +# You may redistribute and modify this Source and make products # +# using it under the terms of the CERN-OHL-S v2 (https:/cern.ch/cern-ohl). # +# This Source is distributed WITHOUT ANY EXPRESS OR IMPLIED # +# WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY AND FITNESS # +# FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-S v2 for applicable # +# conditions. # +# # +# Source location: https://github.com/buildbotics # +# # +# As per CERN-OHL-S v2 section 4, should You produce hardware based on # +# these sources, You must maintain the Source Location clearly visible on # +# the external case of the CNC Controller or other product you make using # +# this Source. # +# # +# For more information, email info@buildbotics.com # +# # +################################################################################ + +import os +import shutil +import bbctrl + + +class FileSystem: + def __init__(self, ctrl): + self.ctrl = ctrl + self.log = ctrl.log.get('FileSystem') + self.locations = ['Home'] + + upload = self.ctrl.root + '/upload' + os.environ['GCODE_SCRIPT_PATH'] = upload + + if not os.path.exists(upload): + os.mkdir(upload) + from shutil import copy + copy(bbctrl.get_resource('http/buildbotics.nc'), upload) + + ctrl.events.on('invalidate', self.invalidate) + ctrl.events.on('invalidate-all', self.invalidate_all) + self.usb_update() + self.queue_next_file() + + + def queue_next_file(self): + upload = self.ctrl.root + '/upload' + + files = [] + for path in os.listdir(upload): + if os.path.isfile(upload + '/' + path): + files.append(path) + + files.sort() + + if len(files): self.queue_file('Home/' + files[0]) + else: self.queue_file('') + + + def realpath(self, path): + path = os.path.normpath(path) + parts = path.split('/', 1) + + if not len(parts): return '' + path = parts[1] if len(parts) == 2 else '' + + if parts[0] == 'Home': return self.ctrl.root + '/upload/' + path + + usb = '/media/' + parts[0] + if os.path.exists(usb): return usb + '/' + path + + return '' + + + def exists(self, path): return os.path.exists(self.realpath(path)) + def isfile(self, path): return os.path.isfile(self.realpath(path)) + + + def invalidate(self, path): + if path == self.ctrl.state.get('queued', ''): + self.ctrl.ioloop.add_callback(self.requeue) + + + def invalidate_all(self): + self.ctrl.ioloop.add_callback(self.requeue) + + + def requeue(self): + self.queue_file(self.ctrl.state.get('queued', '')) + + + def set_bounds(self, bounds): + import json + self.log.info('bounds %s' % json.dumps(bounds)) + + for axis in 'xyzabc': + for name in ('min', 'max'): + value = bounds[name][axis] if axis in bounds[name] else 0 + self.ctrl.state.set('queued_%s_%s' % (name, axis), value) + + + def clear_bounds(self): + for axis in 'xyzabc': + for name in ('min', 'max'): + self.ctrl.state.set('queued_%s_%s' % (name, axis), 0) + + + def queue_file(self, path): + realpath = self.realpath(path) + + if os.path.exists(realpath): modified = os.path.getmtime(realpath) + else: path, modified = '', 0 + + state = self.ctrl.state + state.set('queued', path) + state.set('queued_modified', modified) + state.set('queued_time', 0) + state.set('queued_messages', []) + state.set('line', 0) + self.clear_bounds() + + if not modified: return + + + def check_progress(): + if state.get('queued', '') != path: return + progress = self.ctrl.preplanner.get_plan_progress(path) + state.set('queued_progress', progress) + if progress < 1: self.ctrl.ioloop.call_later(1, check_progress) + + check_progress() + + + def set_state(future): + meta = future.result()[0] + + self.set_bounds(meta['bounds']) + state.set('queued_time', meta['time']) + state.set('queued_messages', meta['messages']) + + future = self.ctrl.preplanner.get_plan(path) + self.ctrl.ioloop.add_future(future, set_state) + + + def delete(self, path): + realpath = self.realpath(path) + + try: + if os.path.isdir(realpath): shutil.rmtree(realpath, True) + else: os.unlink(realpath) + except OSError: pass + + self.log.info('Deleted ' + path) + self.ctrl.events.emit('invalidate', path) + + + def mkdir(self, path): + realpath = self.realpath(path) + + if not os.path.exists(realpath): + os.makedirs(realpath) + os.sync() + + + def write(self, path, data): + realpath = self.realpath(path) + + with open(realpath.encode('utf8'), 'wb') as f: + f.write(data) + + self.log.info('Wrote ' + path) + self.ctrl.events.emit('invalidate', path) + os.sync() + + + def usb_update(self): + self.locations = ['Home'] + + for name in os.listdir('/media'): + if os.path.isdir('/media/' + name): + self.locations.append(name) + + self.ctrl.state.set('locations', self.locations) diff --git a/src/py/bbctrl/FileSystemHandler.py b/src/py/bbctrl/FileSystemHandler.py new file mode 100644 index 0000000..a85baa8 --- /dev/null +++ b/src/py/bbctrl/FileSystemHandler.py @@ -0,0 +1,94 @@ +################################################################################ +# # +# This file is part of the Buildbotics firmware. # +# # +# Copyright (c) 2015 - 2020, Buildbotics LLC, All rights reserved. # +# # +# This Source describes Open Hardware and is licensed under the # +# CERN-OHL-S v2. # +# # +# You may redistribute and modify this Source and make products # +# using it under the terms of the CERN-OHL-S v2 (https:/cern.ch/cern-ohl). # +# This Source is distributed WITHOUT ANY EXPRESS OR IMPLIED # +# WARRANTY, INCLUDING OF MERCHANTABILITY, SATISFACTORY QUALITY AND FITNESS # +# FOR A PARTICULAR PURPOSE. Please see the CERN-OHL-S v2 for applicable # +# conditions. # +# # +# Source location: https://github.com/buildbotics # +# # +# As per CERN-OHL-S v2 section 4, should You produce hardware based on # +# these sources, You must maintain the Source Location clearly visible on # +# the external case of the CNC Controller or other product you make using # +# this Source. # +# # +# For more information, email info@buildbotics.com # +# # +################################################################################ + +import os +import stat +import json +import bbctrl +from datetime import datetime +from tornado import gen +from tornado.web import HTTPError + + +def clean_path(path): + if path is None: return '' + + path = os.path.normpath(path) + if path.startswith('..'): raise HTTPError(400, 'Invalid path') + return path.lstrip('./').replace('#', '-').replace('?', '-') + + +def timestamp_to_iso8601(ts): + return datetime.fromtimestamp(ts).replace(microsecond = 0).isoformat() + 'Z' + + +class FileSystemHandler(bbctrl.RequestHandler): + def get_fs(self): return self.get_ctrl().fs + def delete(self, path): self.get_fs().delete(clean_path(path)) + + + def put(self, path): + path = clean_path(path) + + if 'file' in self.request.files: + self.get_fs().mkdir(os.path.dirname(path)) + file = self.request.files['file'][0] + self.get_fs().write(path, file['body']) + + else: self.get_fs().mkdir(path) + + + @gen.coroutine + def get(self, path): + path = clean_path(path) + if path == '': path = 'Home' + realpath = self.get_fs().realpath(path) + + if not os.path.exists(realpath): raise HTTPError(404, 'File not found') + elif os.path.isdir(realpath): + files = [] + + if os.path.exists(realpath): + for name in os.listdir(realpath): + s = os.stat(realpath + '/' + name) + + d = dict(name = name) + d['created'] = timestamp_to_iso8601(s.st_ctime) + d['modified'] = timestamp_to_iso8601(s.st_mtime) + d['size'] = s.st_size + d['dir'] = stat.S_ISDIR(s.st_mode) + + files.append(d) + + d = dict(path = path, files = files) + + self.set_header('Content-Type', 'application/json') + self.write(json.dumps(d, separators = (',', ':'))) + + else: + with open(realpath.encode('utf8'), 'r') as f: + self.write(f.read()) diff --git a/src/py/bbctrl/IOLoop.py b/src/py/bbctrl/IOLoop.py index e03f5b9..b87442d 100644 --- a/src/py/bbctrl/IOLoop.py +++ b/src/py/bbctrl/IOLoop.py @@ -45,7 +45,7 @@ class CB(object): class IOLoop(object): - READ = tornado.ioloop.IOLoop.READ + READ = tornado.ioloop.IOLoop.READ WRITE = tornado.ioloop.IOLoop.WRITE ERROR = tornado.ioloop.IOLoop.ERROR @@ -91,3 +91,7 @@ class IOLoop(object): def add_callback(self, cb, *args, **kwargs): self.ioloop.add_callback(cb, *args, **kwargs) + + + def add_future(self, future, cb): + self.ioloop.add_future(future, cb) diff --git a/src/py/bbctrl/Mach.py b/src/py/bbctrl/Mach.py index 601e7f1..081473f 100644 --- a/src/py/bbctrl/Mach.py +++ b/src/py/bbctrl/Mach.py @@ -295,10 +295,10 @@ class Mach(Comm): def start(self): - filename = self.ctrl.state.get('selected', '') - if not filename: return + path = self.ctrl.state.get('queued', '') + if not path: return self._begin_cycle('running') - self.planner.load(filename) + self.planner.load(path) super().resume() diff --git a/src/py/bbctrl/MonitorTemp.py b/src/py/bbctrl/MonitorTemp.py index f2987f6..21b9746 100644 --- a/src/py/bbctrl/MonitorTemp.py +++ b/src/py/bbctrl/MonitorTemp.py @@ -104,6 +104,7 @@ class MonitorTemp(object): self.update_camera(temp) self.log_warnings(temp) + except SystemExit: pass except: self.log.exception() self.ioloop.call_later(5, self.callback) diff --git a/src/py/bbctrl/Planner.py b/src/py/bbctrl/Planner.py index 863ad4b..3898ff6 100644 --- a/src/py/bbctrl/Planner.py +++ b/src/py/bbctrl/Planner.py @@ -359,7 +359,7 @@ class Planner(): def load(self, path): self.where = path - path = self.ctrl.get_path('upload', path) + path = self.ctrl.fs.realpath(path) self.log.info('GCode:' + path) self._log_time('Program Start: ') self._sync_position() diff --git a/src/py/bbctrl/Preplanner.py b/src/py/bbctrl/Preplanner.py index 2204649..25bc464 100644 --- a/src/py/bbctrl/Preplanner.py +++ b/src/py/bbctrl/Preplanner.py @@ -63,7 +63,7 @@ def safe_remove(path): class Plan(object): - def __init__(self, preplanner, ctrl, filename): + def __init__(self, preplanner, ctrl, path): self.preplanner = preplanner # Copy planner state @@ -75,9 +75,8 @@ class Plan(object): self.cancel = False self.pid = None - root = ctrl.get_path() - self.gcode = '%s/upload/%s' % (root, filename) - self.base = '%s/plans/%s' % (root, filename) + self.gcode = ctrl.fs.realpath(path) + self.base = '%s/plans/%s' % (ctrl.root, os.path.basename(path)) self.hid = plan_hash(self.gcode, self.config) fbase = '%s.%s.' % (self.base, self.hid) self.files = [ @@ -98,25 +97,6 @@ class Plan(object): except: pass - def delete(self): - files = glob.glob(self.base + '.*') - for path in files: safe_remove(path) - - - def clean(self, max = 2): - plans = glob.glob(self.base + '.*.json') - if len(plans) <= max: return - - # Delete oldest plans - plans = [(os.path.getmtime(path), path) for path in plans] - plans.sort() - - for mtime, path in plans[:len(plans) - max]: - safe_remove(path) - safe_remove(path[:-4] + 'positions.gz') - safe_remove(path[:-4] + 'speeds.gz') - - def _exists(self): for path in self.files: if not os.path.exists(path): return False @@ -144,8 +124,6 @@ class Plan(object): @gen.coroutine def _exec(self): - self.clean() # Clean up old plans - with tempfile.TemporaryDirectory() as tmpdir: cmd = ( '/usr/bin/env', 'python3', @@ -187,6 +165,7 @@ class Plan(object): os.rename(tmpdir + '/meta.json', self.files[0]) os.rename(tmpdir + '/positions.gz', self.files[1]) os.rename(tmpdir + '/speeds.gz', self.files[2]) + self.preplanner.clean() os.sync() @@ -196,6 +175,7 @@ class Plan(object): if self._exists(): data = self._read() if data is not None: + self.progress = 1 self.future.set_result(data) return @@ -221,6 +201,23 @@ class Preplanner(object): self.started = Future() self.plans = {} + ctrl.events.on('invalidate-all', self.invalidate_all) + ctrl.events.on('invalidate', self.invalidate) + + + def clean(self, max = 100): + plans = glob.glob('%s/plans/*.json' % self.ctrl.root) + if len(plans) <= max: return + + # Delete oldest plans + plans = [(os.path.getmtime(path), path) for path in plans] + plans.sort() + + for mtime, path in plans[:len(plans) - max]: + safe_remove(path) + safe_remove(path[:-4] + 'positions.gz') + safe_remove(path[:-4] + 'speeds.gz') + def start(self): if not self.started.done(): @@ -228,44 +225,35 @@ class Preplanner(object): self.started.set_result(True) - def invalidate(self, filename): - if filename in self.plans: - self.plans[filename].terminate() - del self.plans[filename] + def invalidate(self, path): + if path in self.plans: + self.plans[path].terminate() + del self.plans[path] def invalidate_all(self): - for filename, plan in self.plans.items(): + for path, plan in self.plans.items(): plan.terminate() self.plans = {} - def delete_all_plans(self): - files = glob.glob(self.ctrl.get_plan('*')) - for path in files: safe_remove(path) - self.invalidate_all() - - - def delete_plans(self, filename): - if filename in self.plans: - self.plans[filename].delete() - self.invalidate(filename) - @gen.coroutine - def get_plan(self, filename): - if filename is None: raise Exception('Filename cannot be None') + def get_plan(self, path): + if path is None: raise Exception('Path cannot be None') # Wait until state is fully initialized yield self.started - if filename in self.plans: plan = self.plans[filename] + if not self.ctrl.fs.isfile(path): raise Exception('File not found') + + if path in self.plans: plan = self.plans[path] else: - plan = Plan(self, self.ctrl, filename) - self.plans[filename] = plan + plan = Plan(self, self.ctrl, path) + self.plans[path] = plan data = yield plan.future return data - def get_plan_progress(self, filename): - return self.plans[filename].progress if filename in self.plans else 0 + def get_plan_progress(self, path): + return self.plans[path].progress if path in self.plans else 0 diff --git a/src/py/bbctrl/RequestHandler.py b/src/py/bbctrl/RequestHandler.py index 6c08f53..59c18f3 100644 --- a/src/py/bbctrl/RequestHandler.py +++ b/src/py/bbctrl/RequestHandler.py @@ -38,16 +38,16 @@ class RequestHandler(tornado.web.RequestHandler): self.app = app - def get_ctrl(self): return self.app.get_ctrl(self.get_cookie('client-id')) - def get_log(self, name = 'API'): return self.get_ctrl().log.get(name) + def get_ctrl(self): + return self.app.get_ctrl(self.get_cookie('bbctrl-client-id')) - def get_path(self, path = None, filename = None): - return self.get_ctrl().get_path(path, filename) + def get_log(self, name = 'API'): return self.get_ctrl().log.get(name) + def get_events(self): return self.get_ctrl().events - def get_upload(self, filename = None): - return self.get_ctrl().get_upload(filename) + def emit(self, event, *args, **kwargs): + self.get_events().emit(event, *args, **kwargs) # Override exception logging diff --git a/src/py/bbctrl/State.py b/src/py/bbctrl/State.py index 39b77a1..364eab2 100644 --- a/src/py/bbctrl/State.py +++ b/src/py/bbctrl/State.py @@ -77,7 +77,6 @@ class State(object): self.set_callback('timestamp', lambda name: time.time()) self.reset() - self.load_files() def init(self): @@ -97,66 +96,6 @@ class State(object): self.set('offset_' + axis, 0) - def load_files(self): - self.files = [] - - upload = self.ctrl.get_upload() - - if not os.path.exists(upload): - os.mkdir(upload) - from shutil import copy - copy(bbctrl.get_resource('http/buildbotics.nc'), upload) - - for path in os.listdir(upload): - if os.path.isfile(upload + '/' + path): - self.files.append(path) - - self.files.sort() - self.set('files', self.files) - - if len(self.files): self.select_file(self.files[0]) - else: self.select_file('') - - - def clear_files(self): - self.select_file('') - self.files = [] - self.changes['files'] = self.files - - - def add_file(self, filename): - if not filename in self.files: - self.files.append(filename) - self.files.sort() - self.changes['files'] = self.files - - self.select_file(filename) - - - def remove_file(self, filename): - if filename in self.files: - self.files.remove(filename) - self.changes['files'] = self.files - - if self.get('selected', filename) == filename: - if len(self.files): self.select_file(self.files[0]) - else: self.select_file('') - - - def select_file(self, filename): - self.set('selected', filename) - time = os.path.getmtime(self.ctrl.get_upload(filename)) - self.set('selected_time', time) - - - def set_bounds(self, bounds): - for axis in 'xyzabc': - for name in ('min', 'max'): - var = '%s_%s' % (axis, name) - value = bounds[name][axis] if axis in bounds[name] else 0 - self.set(var, value) - - def ack_message(self, id): self.log.info('Message %d acknowledged' % id) msgs = self.vars['messages'] diff --git a/src/py/bbctrl/Web.py b/src/py/bbctrl/Web.py index 2674783..8de58b3 100644 --- a/src/py/bbctrl/Web.py +++ b/src/py/bbctrl/Web.py @@ -84,6 +84,13 @@ class RebootHandler(bbctrl.APIHandler): subprocess.Popen('reboot') +class StateHandler(bbctrl.APIHandler): + def get(self, path): + if path is None or path == '' or path == '/': + self.write_json(self.get_ctrl().state.snapshot()) + else: self.write_json(self.get_ctrl().state.get(path[1:])) + + class LogHandler(bbctrl.RequestHandler): def get(self): with open(self.get_ctrl().log.get_path(), 'r') as f: @@ -124,7 +131,7 @@ class BugReportHandler(bbctrl.RequestHandler): check_add_basename('%s.%d' % (path, i)) check_add_basename('/var/log/syslog') check_add('config.json') - check_add(ctrl.get_upload(ctrl.state.get('selected', ''))) + check_add(ctrl.fs.realpath(ctrl.state.get('queued', ''))) return files @@ -302,21 +309,41 @@ class UpgradeHandler(bbctrl.APIHandler): subprocess.Popen(['/usr/local/bin/upgrade-bbctrl']) +class QueueHandler(bbctrl.APIHandler): + def put_ok(self, path): + path = os.path.normpath(path) + if path.startswith('..'): raise HTTPError(400, 'Invalid path') + path = path.lstrip('./') + + realpath = self.get_ctrl().fs.realpath(path) + if not os.path.exists(realpath): raise HTTPError(404, 'File not found') + self.get_ctrl().fs.queue_file(path) + + +class USBUpdateHandler(bbctrl.APIHandler): + def put_ok(self): self.get_ctrl().fs.usb_update() + + +class USBEjectHandler(bbctrl.APIHandler): + def put_ok(self, path): + subprocess.Popen(['/usr/local/bin/eject-usb', '/media/' + path]) + + class PathHandler(bbctrl.APIHandler): @gen.coroutine - def get(self, filename, dataType, *args): - if not os.path.exists(self.get_upload(filename)): + def get(self, dataType, path, *args): + if not os.path.exists(self.get_ctrl().fs.realpath(path)): raise HTTPError(404, 'File not found') preplanner = self.get_ctrl().preplanner - future = preplanner.get_plan(filename) + future = preplanner.get_plan(path) try: delta = datetime.timedelta(seconds = 1) data = yield gen.with_timeout(delta, future) except gen.TimeoutError: - progress = preplanner.get_plan_progress(filename) + progress = preplanner.get_plan_progress(path) self.write_json(dict(progress = progress)) return @@ -324,14 +351,13 @@ class PathHandler(bbctrl.APIHandler): if data is None: return meta, positions, speeds = data - if dataType == '/positions': data = positions - elif dataType == '/speeds': data = speeds + if dataType == 'positions': data = positions + elif dataType == 'speeds': data = speeds else: - self.get_ctrl().state.set_bounds(meta['bounds']) self.write_json(meta) return - filename = filename + '-' + dataType[1:] + '.gz' + filename = os.path.basename(path) + '-' + dataType + '.gz' self.set_header('Content-Disposition', 'filename="%s"' % filename) self.set_header('Content-Type', 'application/octet-stream') self.set_header('Content-Encoding', 'gzip') @@ -422,7 +448,7 @@ class JogHandler(bbctrl.APIHandler): # Handle possible out of order jog command processing if 'ts' in self.json: ts = self.json['ts'] - id = self.get_cookie('client-id') + id = self.get_cookie('bbctrl-client-id') if not hasattr(self.app, 'last_jog'): self.app.last_jog = {} @@ -498,7 +524,7 @@ class SockJSConnection(ClientConnection, sockjs.tornado.SockJSConnection): def on_open(self, info): - cookie = info.get_cookie('client-id') + cookie = info.get_cookie('bbctrl-client-id') if cookie is None: self.send(dict(sid = '')) # Trigger client reset else: id = cookie.value @@ -535,6 +561,7 @@ class Web(tornado.web.Application): handlers = [ (r'/websocket', WSConnection), + (r'/api/state(/.*)?', StateHandler), (r'/api/log', LogHandler), (r'/api/message/(\d+)/ack', MessageAckHandler), (r'/api/bugreport', BugReportHandler), @@ -549,8 +576,13 @@ class Web(tornado.web.Application): (r'/api/config/reset', ConfigResetHandler), (r'/api/firmware/update', FirmwareUpdateHandler), (r'/api/upgrade', UpgradeHandler), - (r'/api/file(/[^/]+)?', bbctrl.FileHandler), - (r'/api/path/([^/]+)((/positions)|(/speeds))?', PathHandler), + (r'/api/queue/(.*)', QueueHandler), + (r'/api/usb/update', USBUpdateHandler), + (r'/api/usb/eject/(.*)', USBEjectHandler), + (r'/api/fs/(.*)', bbctrl.FileSystemHandler), + (r'/api/(path)/(.*)', PathHandler), + (r'/api/(positions)/(.*)', PathHandler), + (r'/api/(speeds)/(.*)', PathHandler), (r'/api/home(/[xyzabcXYZABC]((/set)|(/clear))?)?', HomeHandler), (r'/api/start', StartHandler), (r'/api/estop', EStopHandler), @@ -618,5 +650,6 @@ class Web(tornado.web.Application): # Override default logger def log_request(self, handler): - log = self.get_ctrl(handler.get_cookie('client-id')).log.get('Web') + ctrl = self.get_ctrl(handler.get_cookie('bbctrl-client-id')) + log = ctrl.log.get('Web') log.info("%d %s", handler.get_status(), handler._request_summary()) diff --git a/src/py/bbctrl/__init__.py b/src/py/bbctrl/__init__.py index 1832fb4..15b19c3 100644 --- a/src/py/bbctrl/__init__.py +++ b/src/py/bbctrl/__init__.py @@ -38,7 +38,8 @@ from pkg_resources import Requirement, resource_filename from bbctrl.RequestHandler import RequestHandler from bbctrl.APIHandler import APIHandler -from bbctrl.FileHandler import FileHandler +from bbctrl.FileSystemHandler import FileSystemHandler +from bbctrl.FileSystem import FileSystem from bbctrl.Config import Config from bbctrl.LCD import LCD, LCDPage from bbctrl.Mach import Mach @@ -58,6 +59,7 @@ from bbctrl.Camera import Camera, VideoHandler from bbctrl.AVR import AVR from bbctrl.AVREmu import AVREmu from bbctrl.IOLoop import IOLoop +from bbctrl.Events import Events from bbctrl.MonitorTemp import MonitorTemp import bbctrl.Cmd as Cmd import bbctrl.v4l2 as v4l2 diff --git a/src/resources/images/isometric.png b/src/resources/images/angled.png similarity index 100% rename from src/resources/images/isometric.png rename to src/resources/images/angled.png diff --git a/src/resources/images/back.png b/src/resources/images/back.png new file mode 100644 index 0000000000000000000000000000000000000000..61579b2495fcdceccd5d73e45d2c527448415525 GIT binary patch literal 4163 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 N002ovPDHLkV1mn&>rMaw literal 0 HcmV?d00001 diff --git a/src/resources/images/bottom.png b/src/resources/images/bottom.png new file mode 100644 index 0000000000000000000000000000000000000000..42f267d66a9e52945e12ad4ef932714549e10354 GIT binary patch literal 4244 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