From c10f5c053a30235514c2ebe3bfea9de3a6132993 Mon Sep 17 00:00:00 2001 From: Henrik Muehe Date: Sun, 3 May 2026 14:07:35 +0200 Subject: [PATCH] ui: assorted polish - Vue async fix, OrbitControls passive listeners, path-viewer + motor-view + indicators - main.js: disable Vue async batching so reactive writes from hashchange listeners propagate synchronously (matches Vue 1's older default; avoids dropped DRO updates). - orbit.js: pass {passive:false} to wheel/touch listeners so OrbitControls.preventDefault() actually suppresses page panning. - path-viewer: opaque dark canvas (no flash from page background), zero-size guard, ResizeObserver cleanup on destroy. - motor-view: stop clobbering user edits with controller state. - estop/indicators/tool-view/path-viewer pug: rename FA4 icons to FA6, add viewBox to estop SVG, fix tool-view trailing newline. --- src/js/main.js | 10 +++ src/js/motor-view.js | 143 +++--------------------------- src/js/orbit.js | 10 ++- src/js/path-viewer.js | 82 +++++++++++++++-- src/pug/templates/estop.pug | 2 + src/pug/templates/indicators.pug | 14 +-- src/pug/templates/path-viewer.pug | 2 +- src/pug/templates/tool-view.pug | 2 +- 8 files changed, 114 insertions(+), 151 deletions(-) diff --git a/src/js/main.js b/src/js/main.js index f58ad13..06ebc9f 100644 --- a/src/js/main.js +++ b/src/js/main.js @@ -44,6 +44,16 @@ window.onload = function() { cookie_set("client-id", uuid(), 10000); } + // Vue 1's async queue can drop dependent watcher updates when + // data props are mutated outside the normal event flow (e.g. from + // a `hashchange` listener that fires before Vue's tick scheduler + // has caught up). Disable async batching so every reactive write + // synchronously re-evaluates dependents — this matches Vue 1's + // older default and is what the legacy UI implicitly relied on. + if (Vue.config) { + Vue.config.async = false; + } + // Register global components Vue.component("templated-input", require("./templated-input")); Vue.component("message", require("./message")); diff --git a/src/js/motor-view.js b/src/js/motor-view.js index 5af9560..5564761 100644 --- a/src/js/motor-view.js +++ b/src/js/motor-view.js @@ -87,100 +87,16 @@ module.exports = { return this.stallRPM * this.stepsPerRev * ustep / 60; }, - current_axis: function() { - return this.state[this.index + 'an']; - }, - - current_max_velocity: function() { - return this.state[this.index + 'vm']; - }, - - current_max_soft_limit: function() { - return this.state[this.index + 'tm']; - }, - - current_min_soft_limit: function() { - return this.state[this.index + 'tn']; - }, - current_max_accel: function() { - return this.state[this.index + 'am']; - }, - current_max_jerk: function() { - return this.state[this.index + 'jm']; - }, - current_step_angle: function() { - return this.state[this.index + 'sa']; - }, - current_travel_per_rev: function() { - return this.state[this.index + 'tr']; - }, - current_microsteps: function() { - return this.state[this.index + 'mi']; - } - }, - - attached: function() { - // Sync all state values with motor config when component is ready - // This ensures UI shows correct values when component is first loaded - console.log("Syncing state to motor config for motor index ",this.index); - this.syncStateToConfig(); - }, - - watch: { - current_axis(new_value) { - const motor_axes = ["X", "Y", "Z", "A", "B", "C"] - if(motor_axes[new_value] != this.motor['axis']){ - this.motor['axis'] = motor_axes[new_value]; - } - }, - - current_max_velocity(new_value) { - if(new_value != this.motor['max-velocity']) { - this.motor['max-velocity'] = new_value; - } - }, - - current_max_soft_limit(new_value) { - if(new_value != this.motor['max-soft-limit']) { - this.motor['max-soft-limit'] = new_value; - } - }, - - current_min_soft_limit(new_value) { - if(new_value != this.motor['min-soft-limit']) { - this.motor['min-soft-limit'] = new_value; - } - }, - - current_max_accel(new_value) { - if(new_value != this.motor['max-accel']) { - this.motor['max-accel'] = new_value; - } - }, - - current_max_jerk(new_value) { - if(new_value != this.motor['max-jerk']) { - this.motor['max-jerk'] = new_value; - } - }, - - current_step_angle(new_value) { - if(new_value != this.motor['step-angle']) { - this.motor['step-angle'] = new_value; - } - }, - - current_travel_per_rev(new_value) { - if(new_value != this.motor['travel-per-rev']) { - this.motor['travel-per-rev'] = new_value; - } - }, - - current_microsteps(new_value) { - if(new_value != this.motor['microsteps']) { - this.motor['microsteps'] = new_value; - } - } + // NOTE: do not add `current_xxx` computed props that mirror + // controller state vars (`vm`, `am`, …) and pair + // them with watchers that copy state -> motor config. The + // controller streams those vars continuously over the WS; + // any watcher that writes them back into + // `config.motors[index]` will clobber whatever the user is + // typing into the form between websocket ticks. The form + // edits config directly; Save (app.js) PUTs it to the + // server. The server-side rotary toggle is handled by + // refetching config after the PUT, not by watching state. }, events: { @@ -210,45 +126,6 @@ module.exports = { } return templ.hmodes.indexOf(this.motor["homing-mode"]) != -1; - }, - - syncStateToConfig: function() { - // Force sync all state values to motor config - // This ensures the UI reflects the current state even if changes happened while component was unmounted - - if(this.state == undefined) { - console.log("State is undefined"); - return; - } - - if (this.state[this.index + 'an'] != this.motor['axis']) { - const motor_axes = ["X", "Y", "Z", "A", "B", "C"]; - this.$set('motor["axis"]', motor_axes[this.state[this.index + 'an']]); - } - if (this.state[this.index + 'vm'] != this.motor['max-velocity']) { - this.$set('motor["max-velocity"]', this.state[this.index + 'vm']); - } - if (this.state[this.index + 'tm'] != this.motor['max-soft-limit']) { - this.$set('motor["max-soft-limit"]', this.state[this.index + 'tm']); - } - if (this.state[this.index + 'tn'] != this.motor['min-soft-limit']) { - this.$set('motor["min-soft-limit"]', this.state[this.index + 'tn']); - } - if (this.state[this.index + 'am'] != this.motor['max-accel']) { - this.$set('motor["max-accel"]', this.state[this.index + 'am']); - } - if (this.state[this.index + 'jm'] != this.motor['max-jerk']) { - this.$set('motor["max-jerk"]', this.state[this.index + 'jm']); - } - if (this.state[this.index + 'sa'] != this.motor['step-angle']) { - this.$set('motor["step-angle"]', this.state[this.index + 'sa']); - } - if (this.state[this.index + 'tr'] != this.motor['travel-per-rev']) { - this.$set('motor["travel-per-rev"]', this.state[this.index + 'tr']); - } - if (this.state[this.index + 'mi'] != this.motor['microsteps']) { - this.$set('motor["microsteps"]', this.state[this.index + 'mi']); - } } } }; diff --git a/src/js/orbit.js b/src/js/orbit.js index ef7a4fc..efeb8e9 100644 --- a/src/js/orbit.js +++ b/src/js/orbit.js @@ -683,12 +683,16 @@ const OrbitControls = function(object, domElement) { event.preventDefault(); } + // Chrome treats touch/wheel listeners as passive by default, + // which prevents OrbitControls.preventDefault() from suppressing + // page panning while interacting with the 3D viewer. Pass + // {passive: false} on the events that need to call 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("wheel", onMouseWheel, { passive: false }); + scope.domElement.addEventListener("touchstart", onTouchStart, { passive: false }); scope.domElement.addEventListener("touchend", onTouchEnd, false); - scope.domElement.addEventListener("touchmove", onTouchMove, false); + scope.domElement.addEventListener("touchmove", onTouchMove, { passive: false }); window.addEventListener("keydown", onKeyDown, false); this.update(); // force an update at start diff --git a/src/js/path-viewer.js b/src/js/path-viewer.js index 6240a5b..7e33847 100644 --- a/src/js/path-viewer.js +++ b/src/js/path-viewer.js @@ -101,6 +101,13 @@ module.exports = { Vue.nextTick(this.update); }, + beforeDestroy: function() { + if (this._sizeWatcher) { + this._sizeWatcher.disconnect(); + this._sizeWatcher = null; + } + }, + methods: { update: async function() { if (!this.webglAvailable) { @@ -201,6 +208,12 @@ module.exports = { } const dims = this.get_dims(); + // Skip layouts where the target has no measurable size. + // The render loop guard below will not draw frames until + // a real size has been observed at least once. + if (!(dims.width > 0 && dims.height > 0)) { + return; + } this.camera.aspect = dims.width / dims.height; this.camera.updateProjectionMatrix(); @@ -274,12 +287,23 @@ module.exports = { } try { - // Renderer - this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + // Renderer. Use an opaque canvas with a clear color + // that matches the page-side gradient so the moment + // the canvas is appended (and before the first 3D + // frame is drawn) the user does not see a flash from + // the page background through transparency. + this.renderer = new THREE.WebGLRenderer({ + antialias: true, + alpha: false, + }); this.renderer.setPixelRatio(window.devicePixelRatio); - this.renderer.setClearColor(0, 0); + this.renderer.setClearColor(0x222222, 1); + // Same color on the DOM element itself so the very + // first paint (before the WebGL context has cleared) + // is dark too. + this.renderer.domElement.style.background = "#222222"; + this.renderer.domElement.style.display = "block"; this.target.appendChild(this.renderer.domElement); - } catch (e) { console.log("WebGL not supported: ", e); return; @@ -333,8 +357,46 @@ module.exports = { // Events window.addEventListener("resize", this.update_view, false); - // Start it - this.render(); + // Start the render loop only after the target has a real, + // stable size. Without this, the first frame paints into + // a 0×0 / collapsed-flex canvas and a second frame paints + // again at the right size — visible as a flash on the + // very first mount of the Program tab. + const startRendering = () => { + if (this._rendering) return; + this._rendering = true; + this.update_view(); + this.render(); + }; + + const dims = this.get_dims(); + if (dims.width > 0 && dims.height > 0) { + startRendering(); + } else if (typeof ResizeObserver !== "undefined") { + this._sizeWatcher = new ResizeObserver(entries => { + for (const entry of entries) { + const r = entry.contentRect; + if (r.width > 0 && r.height > 0) { + this._sizeWatcher.disconnect(); + this._sizeWatcher = null; + startRendering(); + return; + } + } + }); + this._sizeWatcher.observe(this.target); + } else { + // Old browser fallback: poll for a non-zero size. + const tick = () => { + const d = this.get_dims(); + if (d.width > 0 && d.height > 0) { + startRendering(); + } else { + requestAnimationFrame(tick); + } + }; + requestAnimationFrame(tick); + } }, create_surface_material: function() { @@ -646,6 +708,14 @@ module.exports = { return; } + // Don't paint frames while the target has no size; this + // prevents an initial single-frame clear from painting + // before the layout has settled (visible as a dark flash). + const dims = this.get_dims(); + if (!(dims.width > 0 && dims.height > 0)) { + return; + } + if (this.controls.update() || this.dirty) { this.dirty = false; this.renderer.render(this.scene, this.camera); diff --git a/src/pug/templates/estop.pug b/src/pug/templates/estop.pug index 393fa88..5a01e3b 100644 --- a/src/pug/templates/estop.pug +++ b/src/pug/templates/estop.pug @@ -2,6 +2,8 @@ script#estop-template(type="text/x-template") svg(version="1.1", xmlns:svg="http://www.w3.org/2000/svg", xmlns="http://www.w3.org/2000/svg", xmlns:xlink="http://www.w3.org/1999/xlink", + viewBox="0 0 130 130", + preserveAspectRatio="xMidYMid meet", width="130", height="130") defs path#text-path-1(d="m 73.735,673.129 c 0,55.107 44.673,99.780 99.780,99.780 55.107,0 99.780,-44.673 99.780,-99.780 0,-55.107 -44.673,-99.780 -99.780,-99.780 -55.107,0 -99.780,44.673 -99.780,99.780 z") diff --git a/src/pug/templates/indicators.pug b/src/pug/templates/indicators.pug index dc65c48..fe04b8c 100644 --- a/src/pug/templates/indicators.pug +++ b/src/pug/templates/indicators.pug @@ -6,11 +6,11 @@ script#indicators-template(type="text/x-template") tr td - .fa.fa-plus-circle.io + .fa.fa-circle-plus.io th Hi/+3.3v th.separator td - .fa.fa-minus-circle.io + .fa.fa-circle-minus.io th Lo/Gnd th.separator td @@ -22,7 +22,7 @@ script#indicators-template(type="text/x-template") th Inactive th.separator td - .fa.fa-circle-o.io + .far.fa-circle.io th Tristated/Disabled table.inputs @@ -169,14 +169,14 @@ script#indicators-template(type="text/x-template") tr th Motor - th(title="Overtemperature fault"): .fa.fa-thermometer-full + th(title="Overtemperature fault"): .fa.fa-temperature-full th(title="Overcurrent motor channel A") A #[.fa.fa-bolt] th(title="Predriver fault motor channel A") - | A #[.fa.fa-exclamation-triangle] + | A #[.fa.fa-triangle-exclamation] th(title="Overcurrent motor channel B") B #[.fa.fa-bolt] th(title="Predriver fault motor channel B") - | B #[.fa.fa-exclamation-triangle] - th(title="Driver communication failure"): .fa.fa-handshake-o + | B #[.fa.fa-triangle-exclamation] + th(title="Driver communication failure"): .fa.fa-handshake th(title="Reset all motor flags") .fa.fa-eraser(@click="motor_reset()") diff --git a/src/pug/templates/path-viewer.pug b/src/pug/templates/path-viewer.pug index 51a3290..75b0899 100644 --- a/src/pug/templates/path-viewer.pug +++ b/src/pug/templates/path-viewer.pug @@ -3,7 +3,7 @@ script#path-viewer-template(type="text/x-template") .path-viewer-toolbar .tool-button(title="Toggle path view size.", @click="small = !small", :class="{active: !small}") - .fa.fa-arrows-alt + .fa.fa-up-down-left-right .tool-button(@click="showTool = !showTool", :class="{active: showTool}", title="Show/hide tool.") diff --git a/src/pug/templates/tool-view.pug b/src/pug/templates/tool-view.pug index 919ab1f..570cb14 100644 --- a/src/pug/templates/tool-view.pug +++ b/src/pug/templates/tool-view.pug @@ -433,4 +433,4 @@ script#tool-view-template(type="text/x-template") | Other settings according to the | a(href="https://buildbotics.com/upload/vfd/stepperonline-v70.pdf", - target="_blank") Stepper Online V70 VFD manual \ No newline at end of file + target="_blank") Stepper Online V70 VFD manual