From dcb3b0d675db9d4af067e202a259fae6a910b8a5 Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin <6576495+widgetii@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:35:02 +0300 Subject: [PATCH 1/2] focus: judge a filter by how sharply it peaks, not by how big it reads The designer could change a filter but not tell you whether the change was an improvement, so the only feedback was the live number -- and that number is a trap. On the 85H50AI, turning section 3 on took the peak from 25,698 to 55,642 while making the filter worse: that section reads HIGHER as the picture blurs (r = -0.49 against a Laplacian ground truth), so it inflates every reading including the out-of-focus ones. "Make the number go up" is backwards. What actually matters is contrast along a defocus sweep: how far the reading falls off peak as the lens walks away from focus. So "Measure it" walks the lens out eight steps, reads the grid at each, walks it back, and reports the ratio -- "falls to 1/12 of its peak" -- never the peak itself. The lens is walked back in a finally, unconditionally. Stopping, leaving the tab, a camera that stops answering and a thrown error all arrive there, and abandoning someone's focus where an abort happened to leave it is worse than never measuring: the focus is gone and nothing said so. Offered only where focus.move exists; a camera focused by hand cannot be asked to sweep. --- dist/editor.js | 155 ++++++++++++++++++++++++++++++++++++++++++-- src/editor.js | 155 ++++++++++++++++++++++++++++++++++++++++++-- tests/ui-check.html | 84 +++++++++++++++++++++++- 3 files changed, 381 insertions(+), 13 deletions(-) diff --git a/dist/editor.js b/dist/editor.js index e1b89a4..dc481ea 100644 --- a/dist/editor.js +++ b/dist/editor.js @@ -3030,16 +3030,22 @@ export function mountEditor(root, { focusSum = null; focusBest = null; focusErr = null; focusHold = null; } + /* One grid, summarised. Shared by the poll and by the sweep so the two + * cannot come to different conclusions about the same camera. */ + async function readGrid() { + const g = await focus.zones(); + const zones = g.zones.map((z) => (Array.isArray(z) + ? { h1: z[0], h2: z[1], v1: z[2], v2: z[3], y: z[4], hlcnt: z[5] } + : z)); + return summarise(zones, g.rows, g.cols); + } + async function focusTick() { if (!focus) return; const gen = focusGen; let sum = null, err = null; try { - const g = await focus.zones(); - const zones = g.zones.map((z) => (Array.isArray(z) - ? { h1: z[0], h2: z[1], v1: z[2], v2: z[3], y: z[4], hlcnt: z[5] } - : z)); - sum = summarise(zones, g.rows, g.cols); + sum = await readGrid(); } catch (e) { err = e && e.message ? e.message : String(e); } @@ -3269,6 +3275,90 @@ export function mountEditor(root, { let filterGen = 0; let filterBusy = false; + /* + * How sharply the filter peaks, rather than how big a number it makes. + * + * This exists because the obvious way to read the panel is wrong. Turning + * on the third filter section more than doubles the value on a focused + * scene -- and makes the filter worse, because that section reads higher as + * the picture BLURS. Anyone tuning by "make the number go up" ends up with + * a camera that focuses badly and a reading that says it is doing well. + * + * What separates a good filter from a big one is the RATIO between focus + * and not-focus, and nothing on a still scene can show that. So the lens is + * walked away from where it sits, the value read at each step, and the + * spread reported. A filter worth keeping falls away steeply; one that is + * merely loud barely moves. + * + * One direction, and back. A sweep through focus and out the other side + * would measure more, but it has to leave the lens where it found it -- an + * operator who asked for a measurement did not ask to have their focus + * moved -- and every extra step is another chance for the return to fall + * short of where it started. + */ + const SWEEP_STEPS = 8; + const SWEEP_SETTLE_MS = 700; + let sweepGen = 0; + + function sweepStop() { sweepGen++; } + + async function runSweep(say, onStep) { + const gen = ++sweepGen; + const mine = () => gen === sweepGen; + const vals = []; + let out = 0, failed = null; + /* The poll is stopped for the duration: it and the sweep would be + * asking the same camera for the same grid at once, and its answers + * would land in the panel out of step with where the lens actually is. */ + stopFocusPoll(); + try { + for (let i = 0; i <= SWEEP_STEPS; i++) { + if (!mine()) break; + try { + const s = await readGrid(); + if (s.peak !== null) vals.push(s.peak); + } catch (e) { + failed = e && e.message ? e.message : String(e); + break; + } + if (!mine()) break; + onStep(i, SWEEP_STEPS * 2); + if (i === SWEEP_STEPS) break; + focus.move('far'); + out++; + await pause(SWEEP_SETTLE_MS); + } + } finally { + /* Every step taken is a step given back, and NOT conditional on the + * sweep still being the current one. Abandoning the lens where an + * abort found it is worse than never measuring: the operator's + * focus is gone and nothing said so. Stopping, leaving the tab, a + * camera that stopped answering and a thrown error all arrive + * here. */ + for (let i = 0; i < out; i++) { + try { + focus.move('near'); + } catch (e) { /* nothing left to try */ } + await pause(SWEEP_SETTLE_MS); + onStep(SWEEP_STEPS + i + 1, SWEEP_STEPS * 2); + } + try { focus.move('stop'); } catch (e) { /* as above */ } + /* The panel is useless without its live grid, and a camera left + * un-polled looks broken. */ + if (mode === 'focus') startFocusPoll(); + } + if (failed) return { failed: failed }; + if (!mine()) return null; + if (!vals.length) return { failed: 'nothing measurable along the sweep' }; + const hi = Math.max.apply(null, vals), lo = Math.min.apply(null, vals); + return { hi: hi, lo: lo, ratio: lo > 0 ? hi / lo : null, n: vals.length }; + } + + + function pause(ms) { + return new Promise(function (r) { setTimeout(r, ms); }); + } + function buildFilterDesigner(panel) { const box = el('div', 're-panel'); box.style.marginTop = '10px'; @@ -3281,6 +3371,9 @@ export function mountEditor(root, { })); const status = el('div'); + /* Named so a reader -- or a test -- can tell this panel's message from + * the grid's own above it; both are notices and both can be warnings. */ + status.dataset.act = 'af-status'; box.append(status); const rows = el('div'); box.append(rows); @@ -3368,6 +3461,21 @@ export function mountEditor(root, { back.dataset.act = 'af-reload'; back.textContent = 'Read from camera'; acts.append(send, back); + + /* Only where the lens can be driven. Measuring how sharply a filter + * peaks means moving the focus, and a camera focused by hand cannot be + * asked to do that from here. */ + let measure = null, stop = null; + if (typeof focus.move === 'function') { + measure = el('button', 're-btn', ''); + measure.dataset.act = 'af-measure'; + measure.textContent = 'Measure it'; + stop = el('button', 're-btn', ''); + stop.dataset.act = 'af-measure-stop'; + stop.textContent = 'Stop'; + stop.hidden = true; + acts.append(measure, stop); + } box.append(acts); const say = (msg, warn) => { @@ -3483,6 +3591,40 @@ export function mountEditor(root, { }); }); + if (measure) { + const busy = (on) => { + measure.hidden = on; + stop.hidden = !on; + send.disabled = on; + back.disabled = on; + }; + stop.addEventListener('click', function () { sweepStop(); }); + measure.addEventListener('click', function () { + if (busyHolding(status)) return; + busy(true); + runSweep(say, function (i, n) { + say('Walking the lens and reading as it goes — ' + i + ' of ' + n + + '. It will be put back where it started.'); + }).then(function (r) { + busy(false); + if (!r) { say('Stopped. The lens is back where it started.'); return; } + if (r.failed) { say('Could not measure it: ' + r.failed, true); return; } + /* The ratio, not the peak. A filter that reads loudly + * everywhere is worse than a quiet one that falls away, + * and the peak alone cannot tell them apart -- which is + * exactly the mistake this button exists to prevent. */ + say(r.ratio === null + ? 'Highest ' + r.hi + ', lowest ' + r.lo + ' across the sweep.' + : 'Falls to 1/' + r.ratio.toFixed(1) + ' of its peak across the ' + + 'sweep (' + r.hi + ' down to ' + r.lo + '). A filter worth ' + + 'keeping falls away steeply; a loud one barely moves.'); + }).catch(function (e) { + busy(false); + say('Could not measure it: ' + (e && e.message ? e.message : e), true); + }); + }); + } + load(); panel.append(box); } @@ -3620,7 +3762,7 @@ export function mountEditor(root, { abandonHold(); /* A poll that outlived its tab would keep a camera answering for a * panel nobody is looking at. */ - if (m !== 'focus') { stopFocusPoll(); moveRelease(); focusStatus = null; } + if (m !== 'focus') { stopFocusPoll(); moveRelease(); sweepStop(); focusStatus = null; } /* Anything a filter write has outstanding belonged to the panel that is * going. Its answer must not come back and arm a trial here. */ filterGen++; @@ -3901,6 +4043,7 @@ export function mountEditor(root, { stopHold(); stopFocusPoll(); moveRelease(); + sweepStop(); filterGen++; abandonAll('the editor was closed'); worker?.terminate(); diff --git a/src/editor.js b/src/editor.js index e1b89a4..dc481ea 100644 --- a/src/editor.js +++ b/src/editor.js @@ -3030,16 +3030,22 @@ export function mountEditor(root, { focusSum = null; focusBest = null; focusErr = null; focusHold = null; } + /* One grid, summarised. Shared by the poll and by the sweep so the two + * cannot come to different conclusions about the same camera. */ + async function readGrid() { + const g = await focus.zones(); + const zones = g.zones.map((z) => (Array.isArray(z) + ? { h1: z[0], h2: z[1], v1: z[2], v2: z[3], y: z[4], hlcnt: z[5] } + : z)); + return summarise(zones, g.rows, g.cols); + } + async function focusTick() { if (!focus) return; const gen = focusGen; let sum = null, err = null; try { - const g = await focus.zones(); - const zones = g.zones.map((z) => (Array.isArray(z) - ? { h1: z[0], h2: z[1], v1: z[2], v2: z[3], y: z[4], hlcnt: z[5] } - : z)); - sum = summarise(zones, g.rows, g.cols); + sum = await readGrid(); } catch (e) { err = e && e.message ? e.message : String(e); } @@ -3269,6 +3275,90 @@ export function mountEditor(root, { let filterGen = 0; let filterBusy = false; + /* + * How sharply the filter peaks, rather than how big a number it makes. + * + * This exists because the obvious way to read the panel is wrong. Turning + * on the third filter section more than doubles the value on a focused + * scene -- and makes the filter worse, because that section reads higher as + * the picture BLURS. Anyone tuning by "make the number go up" ends up with + * a camera that focuses badly and a reading that says it is doing well. + * + * What separates a good filter from a big one is the RATIO between focus + * and not-focus, and nothing on a still scene can show that. So the lens is + * walked away from where it sits, the value read at each step, and the + * spread reported. A filter worth keeping falls away steeply; one that is + * merely loud barely moves. + * + * One direction, and back. A sweep through focus and out the other side + * would measure more, but it has to leave the lens where it found it -- an + * operator who asked for a measurement did not ask to have their focus + * moved -- and every extra step is another chance for the return to fall + * short of where it started. + */ + const SWEEP_STEPS = 8; + const SWEEP_SETTLE_MS = 700; + let sweepGen = 0; + + function sweepStop() { sweepGen++; } + + async function runSweep(say, onStep) { + const gen = ++sweepGen; + const mine = () => gen === sweepGen; + const vals = []; + let out = 0, failed = null; + /* The poll is stopped for the duration: it and the sweep would be + * asking the same camera for the same grid at once, and its answers + * would land in the panel out of step with where the lens actually is. */ + stopFocusPoll(); + try { + for (let i = 0; i <= SWEEP_STEPS; i++) { + if (!mine()) break; + try { + const s = await readGrid(); + if (s.peak !== null) vals.push(s.peak); + } catch (e) { + failed = e && e.message ? e.message : String(e); + break; + } + if (!mine()) break; + onStep(i, SWEEP_STEPS * 2); + if (i === SWEEP_STEPS) break; + focus.move('far'); + out++; + await pause(SWEEP_SETTLE_MS); + } + } finally { + /* Every step taken is a step given back, and NOT conditional on the + * sweep still being the current one. Abandoning the lens where an + * abort found it is worse than never measuring: the operator's + * focus is gone and nothing said so. Stopping, leaving the tab, a + * camera that stopped answering and a thrown error all arrive + * here. */ + for (let i = 0; i < out; i++) { + try { + focus.move('near'); + } catch (e) { /* nothing left to try */ } + await pause(SWEEP_SETTLE_MS); + onStep(SWEEP_STEPS + i + 1, SWEEP_STEPS * 2); + } + try { focus.move('stop'); } catch (e) { /* as above */ } + /* The panel is useless without its live grid, and a camera left + * un-polled looks broken. */ + if (mode === 'focus') startFocusPoll(); + } + if (failed) return { failed: failed }; + if (!mine()) return null; + if (!vals.length) return { failed: 'nothing measurable along the sweep' }; + const hi = Math.max.apply(null, vals), lo = Math.min.apply(null, vals); + return { hi: hi, lo: lo, ratio: lo > 0 ? hi / lo : null, n: vals.length }; + } + + + function pause(ms) { + return new Promise(function (r) { setTimeout(r, ms); }); + } + function buildFilterDesigner(panel) { const box = el('div', 're-panel'); box.style.marginTop = '10px'; @@ -3281,6 +3371,9 @@ export function mountEditor(root, { })); const status = el('div'); + /* Named so a reader -- or a test -- can tell this panel's message from + * the grid's own above it; both are notices and both can be warnings. */ + status.dataset.act = 'af-status'; box.append(status); const rows = el('div'); box.append(rows); @@ -3368,6 +3461,21 @@ export function mountEditor(root, { back.dataset.act = 'af-reload'; back.textContent = 'Read from camera'; acts.append(send, back); + + /* Only where the lens can be driven. Measuring how sharply a filter + * peaks means moving the focus, and a camera focused by hand cannot be + * asked to do that from here. */ + let measure = null, stop = null; + if (typeof focus.move === 'function') { + measure = el('button', 're-btn', ''); + measure.dataset.act = 'af-measure'; + measure.textContent = 'Measure it'; + stop = el('button', 're-btn', ''); + stop.dataset.act = 'af-measure-stop'; + stop.textContent = 'Stop'; + stop.hidden = true; + acts.append(measure, stop); + } box.append(acts); const say = (msg, warn) => { @@ -3483,6 +3591,40 @@ export function mountEditor(root, { }); }); + if (measure) { + const busy = (on) => { + measure.hidden = on; + stop.hidden = !on; + send.disabled = on; + back.disabled = on; + }; + stop.addEventListener('click', function () { sweepStop(); }); + measure.addEventListener('click', function () { + if (busyHolding(status)) return; + busy(true); + runSweep(say, function (i, n) { + say('Walking the lens and reading as it goes — ' + i + ' of ' + n + + '. It will be put back where it started.'); + }).then(function (r) { + busy(false); + if (!r) { say('Stopped. The lens is back where it started.'); return; } + if (r.failed) { say('Could not measure it: ' + r.failed, true); return; } + /* The ratio, not the peak. A filter that reads loudly + * everywhere is worse than a quiet one that falls away, + * and the peak alone cannot tell them apart -- which is + * exactly the mistake this button exists to prevent. */ + say(r.ratio === null + ? 'Highest ' + r.hi + ', lowest ' + r.lo + ' across the sweep.' + : 'Falls to 1/' + r.ratio.toFixed(1) + ' of its peak across the ' + + 'sweep (' + r.hi + ' down to ' + r.lo + '). A filter worth ' + + 'keeping falls away steeply; a loud one barely moves.'); + }).catch(function (e) { + busy(false); + say('Could not measure it: ' + (e && e.message ? e.message : e), true); + }); + }); + } + load(); panel.append(box); } @@ -3620,7 +3762,7 @@ export function mountEditor(root, { abandonHold(); /* A poll that outlived its tab would keep a camera answering for a * panel nobody is looking at. */ - if (m !== 'focus') { stopFocusPoll(); moveRelease(); focusStatus = null; } + if (m !== 'focus') { stopFocusPoll(); moveRelease(); sweepStop(); focusStatus = null; } /* Anything a filter write has outstanding belonged to the panel that is * going. Its answer must not come back and arm a trial here. */ filterGen++; @@ -3901,6 +4043,7 @@ export function mountEditor(root, { stopHold(); stopFocusPoll(); moveRelease(); + sweepStop(); filterGen++; abandonAll('the editor was closed'); worker?.terminate(); diff --git a/tests/ui-check.html b/tests/ui-check.html index 22ae253..c96c8e0 100644 --- a/tests/ui-check.html +++ b/tests/ui-check.html @@ -2010,9 +2010,28 @@ let grid = opts.grid || GRID; const focus = { intervalMs: 40, - zones: () => Promise.resolve(grid), + zones: () => { + seen.reads = (seen.reads || 0) + 1; + // From that read ONWARDS, not on it alone: the live poll reads + // too, so an exact count picks whichever of the two got there + // first, and the sweep saw a healthy camera the once it mattered. + if (opts.readFailsAt && seen.reads >= opts.readFailsAt) + return Promise.reject(new Error('the camera stopped answering')); + return Promise.resolve(opts.sweepGrid ? opts.sweepGrid(seen.pos || 0) : grid); + }, }; seen.setGrid = (g) => { grid = g; }; + if (opts.lens) { + seen.moves = []; + seen.pos = 0; + focus.move = (v) => { + seen.moves.push(v); + if (v === 'far') seen.pos++; + if (v === 'near') seen.pos--; + return Promise.resolve(); + }; + focus.moveRepeatMs = 20; + } if (!opts.readOnly) { focus.holdSeconds = opts.hold || 30; focus.filters = () => (opts.readFails @@ -2296,6 +2315,69 @@ t('and works again once it has landed', g.seen.applied.length === madeBefore + 1, String(g.seen.applied.length)); g.ed.destroy(); + // Measuring how sharply a filter peaks means MOVING the lens, so every + // check below is about it going back. An operator who asked for a + // measurement did not ask to have their focus moved. + + // No lens, no button: it cannot be done from here. + g = await mk({}); + t('no way to measure without a lens to drive', !q(g.host, 'af-measure')); + g.ed.destroy(); + + // A filter that falls away steeply is the one worth keeping. The peak + // alone cannot tell it from one that is merely loud everywhere, which is + // the mistake this exists to prevent. + g = await mk({ lens: true, sweepGrid: (pos) => ({ rows: 1, cols: 2, zones: [ + // sharp at the start, falling as the lens walks away + [0, Math.max(10, 900 - pos * 110), 0, 9, 1000, 0], [0, 10, 0, 1, 1000, 0]] }) }); + t('a lens grows the button', !!q(g.host, 'af-measure')); + q(g.host, 'af-measure').click(); + for (let i = 0; i < 200 && q(g.host, 'af-measure').hidden; i++) await pause(100); + const said = q(g.host, 'af-status') ? q(g.host, 'af-status').textContent : ''; + t('it reports how far the reading falls, not how big it got', + /Falls to 1\/\d/.test(said), said.slice(0, 90)); + // Out and back, step for step. + const far = g.seen.moves.filter((m) => m === 'far').length; + const near = g.seen.moves.filter((m) => m === 'near').length; + t('the lens is walked out and walked back the same distance', + far === near && far > 0, far + ' out, ' + near + ' back'); + t('and ends where it started', g.seen.pos === 0, String(g.seen.pos)); + g.ed.destroy(); + + // Stopping half way is the case that most needs the lens returned. + g = await mk({ lens: true }); + q(g.host, 'af-measure').click(); + await pause(1600); + q(g.host, 'af-measure-stop').click(); + // Waited out rather than guessed: the walk back takes as many steps as + // were taken out, and a fixed pause that was short would report the lens + // stranded when it was merely still moving. + for (let i = 0; i < 200 && q(g.host, 'af-measure').hidden; i++) await pause(100); + t('stopping still brings the lens back', g.seen.pos === 0, String(g.seen.pos)); + t('and says so', /back where it started/.test( + q(g.host, 'af-status') ? q(g.host, 'af-status').textContent : ''), + q(g.host, 'af-status') ? q(g.host, 'af-status').textContent.slice(0, 60) : ''); + g.ed.destroy(); + + // Leaving the tab mid-sweep, which is the same problem without a button. + g = await mk({ lens: true }); + q(g.host, 'af-measure').click(); + await pause(1600); + [...g.host.querySelectorAll('.re-seg button')].find((b) => b.textContent.trim() === 'Develop').click(); + for (let i = 0; i < 200 && g.seen.pos !== 0; i++) await pause(100); + t('leaving the tab brings it back too', g.seen.pos === 0, String(g.seen.pos)); + g.ed.destroy(); + + // A camera that stops answering half way through. + g = await mk({ lens: true, readFailsAt: 3 }); + q(g.host, 'af-measure').click(); + for (let i = 0; i < 200 && q(g.host, 'af-measure').hidden; i++) await pause(100); + t('a camera that stops answering is reported', + /Could not measure/.test(q(g.host, 'af-status') ? q(g.host, 'af-status').textContent : ''), + q(g.host, 'af-status') ? q(g.host, 'af-status').textContent.slice(0, 70) : '(none)'); + t('and the button comes back', q(g.host, 'af-measure').hidden === false); + g.ed.destroy(); + } catch (e) { t('the filter designer is handled', false, e.message); } From e2197e3ed23f4d697535c9a869c5068c4e4756a7 Mon Sep 17 00:00:00 2001 From: Dmitry Ilyin <6576495+widgetii@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:04:36 +0300 Subject: [PATCH 2/2] focus: the sweep owns the lens, and says so when it cannot give it back Four findings from review, all real, three of them one root: the sweep drives the lens for several seconds and nothing else in the module knew it. A move the sweep can observe. It called focus.move() raw, so a host that reports failure by REJECTING -- which is what a fetch-backed host does -- left the rejection unhandled and the step counted anyway. A counted step that never happened is a step handed back that was never taken, which walks the lens PAST where the operator left it. moveOnce() reports the outcome; a refused step out ends the sweep, and a refused step back is counted and said out loud rather than papered over with "the lens is back where it started". Sweeps are serialised. Aborting one bumps the generation, which stops it measuring but cannot stop it walking back -- that part must finish. So for a few seconds after leaving Focus there is still a sweep sending `near`, and a new one starting then sends `far` against it. A new sweep now waits for the old one's walk home, and is dropped if it was cancelled while it waited. A destroyed editor does not resume polling. destroy() stops the poll and empties the root without touching `mode`, so a finally asking only "still in Focus?" answered yes for a panel that no longer existed and read the camera for as long as the page stayed open. Near and Far are held down for the duration, both halves load-bearing: the buttons are disabled, and the handler refuses, for anything that reaches one another way. Uncounted travel mid-measurement invalidates the ratio and the claim about where the lens ended up. Nine more checks. Each was mutation-tested -- breaking the guard it covers turns exactly that check red, and no other. --- dist/editor.js | 139 ++++++++++++++++++++++++++++++++++++++------ src/editor.js | 139 ++++++++++++++++++++++++++++++++++++++------ tests/ui-check.html | 90 ++++++++++++++++++++++++++++ 3 files changed, 334 insertions(+), 34 deletions(-) diff --git a/dist/editor.js b/dist/editor.js index dc481ea..8ab71eb 100644 --- a/dist/editor.js +++ b/dist/editor.js @@ -3205,9 +3205,44 @@ export function mountEditor(root, { moveSend(moveVerb, moveRelease); } + /* The sweep needs to KNOW whether a move happened, where the hold-to-run + * buttons only need to stop asking: it counts the steps it took out so it + * can give exactly that many back, and a step it counted but the camera + * refused is a step it will hand back one too many of. So this one reports + * the outcome instead of calling back, and treats a rejection and a throw + * alike -- a host may do either. */ + function moveOnce(verb) { + let p; + try { + p = focus.move(verb); + } catch (e) { + return Promise.resolve(false); + } + if (p && typeof p.then === 'function') { + return p.then(function () { return true; }, function () { return false; }); + } + return Promise.resolve(true); + } + + /* The sweep drives the lens for several seconds at a time, and Near and Far + * drive the same lens. Uncounted travel in the middle of a measurement + * invalidates both the ratio and the claim that the lens went back where it + * started, so for the duration the buttons are held down rather than + * trusted: disabled where they are visible, and refused in the handler in + * case anything reaches one another way (a keyboard, a stale pointer). */ + let motorBtns = []; + let lensOwned = false; + + function ownLens(on) { + lensOwned = on; + motorBtns.forEach(function (b) { b.disabled = on; }); + if (on) moveRelease(); + } + function holdToRun(btn, verb) { const press = function (ev) { if (ev && ev.button !== undefined && ev.button !== 0) return; + if (lensOwned) return; /* One hold at a time, and a second pointer is ignored rather than * taking over. Taking over meant releasing EITHER pointer stopped * whatever was running, so the finger lifted was not necessarily @@ -3242,6 +3277,7 @@ export function mountEditor(root, { * configured with, and treating one as a fresh press turns a held * key into a burst of nudges at a cadence this side never chose. */ if (ev.repeat) return; + if (lensOwned) return; ev.preventDefault(); moveSend(verb, null); }); @@ -3299,18 +3335,51 @@ export function mountEditor(root, { const SWEEP_STEPS = 8; const SWEEP_SETTLE_MS = 700; let sweepGen = 0; + /* A sweep in flight when the editor is torn down still has a return walk to + * finish -- the lens is real and must go back -- but what it must NOT do is + * restart the poll afterwards. destroy() stops the poll and empties the + * root without changing `mode`, so a finally that asks only "are we still in + * Focus?" answers yes for a panel that no longer exists and reads the + * camera for as long as the page is open. */ + let sweepClosed = false; + /* Non-null while a sweep owns the lens, INCLUDING its return walk. Aborting + * a sweep bumps the generation, which stops it measuring; it cannot stop it + * walking back, and for those few seconds there is still a sweep sending + * `near`. A second sweep starting then sends `far` against it, and neither + * the reading nor the final position means anything. */ + let sweepBusy = null; function sweepStop() { sweepGen++; } - async function runSweep(say, onStep) { + function runSweep(say, onStep) { + const prior = sweepBusy; + /* The generation this was ASKED under. A sweep queued behind another's + * walk back can be cancelled before it ever starts -- the operator left + * Focus again while it waited -- and starting then would drive the lens + * for a panel nobody is looking at. */ + const at = sweepGen; + const p = (async function () { + if (prior) { try { await prior; } catch (e) { /* not ours to report */ } } + if (sweepGen !== at) return null; + return sweepRun(say, onStep); + })(); + sweepBusy = p; + const clear = function () { if (sweepBusy === p) sweepBusy = null; }; + p.then(clear, clear); + return p; + } + + async function sweepRun(say, onStep) { const gen = ++sweepGen; const mine = () => gen === sweepGen; const vals = []; - let out = 0, failed = null; + let out = 0, failed = null, lost = 0; /* The poll is stopped for the duration: it and the sweep would be * asking the same camera for the same grid at once, and its answers - * would land in the panel out of step with where the lens actually is. */ + * would land in the panel out of step with where the lens actually is. + * The manual controls go with it -- see ownLens. */ stopFocusPoll(); + ownLens(true); try { for (let i = 0; i <= SWEEP_STEPS; i++) { if (!mine()) break; @@ -3324,7 +3393,13 @@ export function mountEditor(root, { if (!mine()) break; onStep(i, SWEEP_STEPS * 2); if (i === SWEEP_STEPS) break; - focus.move('far'); + /* Counted only if it actually happened. A refused move that was + * counted anyway is a step handed back that was never taken, + * which walks the lens PAST where the operator left it. */ + if (!(await moveOnce('far'))) { + failed = 'the camera would not move the lens'; + break; + } out++; await pause(SWEEP_SETTLE_MS); } @@ -3336,22 +3411,33 @@ export function mountEditor(root, { * camera that stopped answering and a thrown error all arrive * here. */ for (let i = 0; i < out; i++) { - try { - focus.move('near'); - } catch (e) { /* nothing left to try */ } + /* A refused step does not stop the walk: the next one may well + * be taken, and stopping early strands the lens further out + * than carrying on can. It is counted, and said out loud at the + * end -- claiming the lens is back when a step was refused is + * the one outcome worse than saying nothing. */ + if (!(await moveOnce('near'))) lost++; await pause(SWEEP_SETTLE_MS); onStep(SWEEP_STEPS + i + 1, SWEEP_STEPS * 2); } - try { focus.move('stop'); } catch (e) { /* as above */ } + await moveOnce('stop'); + ownLens(false); /* The panel is useless without its live grid, and a camera left - * un-polled looks broken. */ - if (mode === 'focus') startFocusPoll(); + * un-polled looks broken -- but only for a panel that still exists. + * `mode` alone does not answer that: destroy() leaves it as it + * found it. */ + if (mode === 'focus' && !sweepClosed) startFocusPoll(); } - if (failed) return { failed: failed }; - if (!mine()) return null; - if (!vals.length) return { failed: 'nothing measurable along the sweep' }; + const back = lost + ? ' The lens may not be back where it started: the camera refused ' + + lost + ' of the ' + out + ' steps back.' + : ''; + if (failed) return { failed: failed + '.' + back, lost: lost }; + if (!mine()) return { stopped: true, lost: lost, back: back }; + if (!vals.length) return { failed: 'nothing measurable along the sweep.' + back, lost: lost }; const hi = Math.max.apply(null, vals), lo = Math.min.apply(null, vals); - return { hi: hi, lo: lo, ratio: lo > 0 ? hi / lo : null, n: vals.length }; + return { hi: hi, lo: lo, ratio: lo > 0 ? hi / lo : null, n: vals.length, + lost: lost, back: back }; } @@ -3607,17 +3693,27 @@ export function mountEditor(root, { '. It will be put back where it started.'); }).then(function (r) { busy(false); - if (!r) { say('Stopped. The lens is back where it started.'); return; } + /* Cancelled before it ever started, behind another sweep's + * walk back. Nothing was measured and nothing was moved, so + * there is nothing to report either. */ + if (!r) { say(''); return; } + if (r.stopped) { + say(r.lost + ? 'Stopped.' + r.back + : 'Stopped. The lens is back where it started.', !!r.lost); + return; + } if (r.failed) { say('Could not measure it: ' + r.failed, true); return; } /* The ratio, not the peak. A filter that reads loudly * everywhere is worse than a quiet one that falls away, * and the peak alone cannot tell them apart -- which is * exactly the mistake this button exists to prevent. */ - say(r.ratio === null + say((r.ratio === null ? 'Highest ' + r.hi + ', lowest ' + r.lo + ' across the sweep.' : 'Falls to 1/' + r.ratio.toFixed(1) + ' of its peak across the ' + 'sweep (' + r.hi + ' down to ' + r.lo + '). A filter worth ' + - 'keeping falls away steeply; a loud one barely moves.'); + 'keeping falls away steeply; a loud one barely moves.') + r.back, + !!r.lost); }).catch(function (e) { busy(false); say('Could not measure it: ' + (e && e.message ? e.message : e), true); @@ -3631,6 +3727,10 @@ export function mountEditor(root, { function buildFocus() { insp.replaceChildren(); + /* The panel is rebuilt from scratch on every entry, so the buttons the + * last one made are detached and must not be kept -- disabling a node + * nobody can see is a leak that also hides a bug. */ + motorBtns = []; const panel = el('div', 're-panel'); panel.append(Object.assign(el('div', 're-shead'), { innerHTML: '