diff --git a/dist/editor.js b/dist/editor.js
index e1b89a4..8ab71eb 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);
}
@@ -3199,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
@@ -3236,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);
});
@@ -3269,6 +3311,140 @@ 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;
+ /* 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++; }
+
+ 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, 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.
+ * The manual controls go with it -- see ownLens. */
+ stopFocusPoll();
+ ownLens(true);
+ 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;
+ /* 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);
+ }
+ } 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++) {
+ /* 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);
+ }
+ await moveOnce('stop');
+ ownLens(false);
+ /* The panel is useless without its live grid, and a camera left
+ * 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();
+ }
+ 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,
+ lost: lost, back: back };
+ }
+
+
+ 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 +3457,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 +3547,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,12 +3677,60 @@ 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);
+ /* 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
+ ? '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.') + r.back,
+ !!r.lost);
+ }).catch(function (e) {
+ busy(false);
+ say('Could not measure it: ' + (e && e.message ? e.message : e), true);
+ });
+ });
+ }
+
load();
panel.append(box);
}
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: '
Focus
',
@@ -3519,7 +3761,9 @@ export function mountEditor(root, {
const b = el('button', 're-btn', '');
b.dataset.act = 'focus-' + pair[0];
b.textContent = pair[1];
+ b.disabled = lensOwned;
holdToRun(b, pair[0]);
+ motorBtns.push(b);
row.append(b);
});
}
@@ -3620,7 +3864,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 +4145,10 @@ export function mountEditor(root, {
stopHold();
stopFocusPoll();
moveRelease();
+ sweepStop();
+ // The walk back still has to happen -- the lens is real -- but
+ // nothing after it may touch a panel that is being removed.
+ sweepClosed = true;
filterGen++;
abandonAll('the editor was closed');
worker?.terminate();
diff --git a/src/editor.js b/src/editor.js
index e1b89a4..8ab71eb 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);
}
@@ -3199,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
@@ -3236,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);
});
@@ -3269,6 +3311,140 @@ 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;
+ /* 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++; }
+
+ 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, 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.
+ * The manual controls go with it -- see ownLens. */
+ stopFocusPoll();
+ ownLens(true);
+ 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;
+ /* 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);
+ }
+ } 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++) {
+ /* 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);
+ }
+ await moveOnce('stop');
+ ownLens(false);
+ /* The panel is useless without its live grid, and a camera left
+ * 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();
+ }
+ 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,
+ lost: lost, back: back };
+ }
+
+
+ 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 +3457,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 +3547,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,12 +3677,60 @@ 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);
+ /* 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
+ ? '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.') + r.back,
+ !!r.lost);
+ }).catch(function (e) {
+ busy(false);
+ say('Could not measure it: ' + (e && e.message ? e.message : e), true);
+ });
+ });
+ }
+
load();
panel.append(box);
}
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: 'Focus
',
@@ -3519,7 +3761,9 @@ export function mountEditor(root, {
const b = el('button', 're-btn', '');
b.dataset.act = 'focus-' + pair[0];
b.textContent = pair[1];
+ b.disabled = lensOwned;
holdToRun(b, pair[0]);
+ motorBtns.push(b);
row.append(b);
});
}
@@ -3620,7 +3864,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 +4145,10 @@ export function mountEditor(root, {
stopHold();
stopFocusPoll();
moveRelease();
+ sweepStop();
+ // The walk back still has to happen -- the lens is real -- but
+ // nothing after it may touch a panel that is being removed.
+ sweepClosed = true;
filterGen++;
abandonAll('the editor was closed');
worker?.terminate();
diff --git a/tests/ui-check.html b/tests/ui-check.html
index 22ae253..d8c90d9 100644
--- a/tests/ui-check.html
+++ b/tests/ui-check.html
@@ -2010,9 +2010,37 @@
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.trail = [];
+ seen.pos = 0;
+ focus.move = (v) => {
+ seen.moves.push(v);
+ // A camera that REJECTS rather than throws, which is the shape
+ // a fetch-backed host actually fails in. The lens does not move
+ // when it does -- that is the whole point of counting only
+ // moves that were accepted.
+ if (opts.moveFails === v || opts.moveFails === true)
+ return Promise.reject(new Error('the lens is jammed'));
+ if (v === 'far') seen.pos++;
+ if (v === 'near') seen.pos--;
+ seen.trail.push(v + '@' + seen.pos);
+ seen.lastMove = Date.now();
+ return Promise.resolve();
+ };
+ focus.moveRepeatMs = 20;
+ }
if (!opts.readOnly) {
focus.holdSeconds = opts.hold || 30;
focus.filters = () => (opts.readFails
@@ -2296,6 +2324,150 @@
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();
+
+ // A lens that will not move at all. A rejected move is the shape a
+ // fetch-backed host fails in, and counting a refused step as taken would
+ // hand back a step never taken -- walking the lens PAST where it started.
+ g = await mk({ lens: true, moveFails: 'far' });
+ q(g.host, 'af-measure').click();
+ for (let i = 0; i < 200 && q(g.host, 'af-measure').hidden; i++) await pause(100);
+ t('a lens that will not move is reported, not measured',
+ /Could not measure/.test(q(g.host, 'af-status').textContent),
+ q(g.host, 'af-status').textContent.slice(0, 70));
+ t('and a refused step is never handed back', g.seen.pos === 0, String(g.seen.pos));
+ g.ed.destroy();
+
+ // It went out but will not come back. Claiming the lens is where it started
+ // when the camera refused every step back is worse than saying nothing.
+ g = await mk({ lens: true, moveFails: 'near' });
+ q(g.host, 'af-measure').click();
+ for (let i = 0; i < 300 && q(g.host, 'af-measure').hidden; i++) await pause(100);
+ t('a lens that will not come back says so, instead of claiming it is back',
+ /may not be back/.test(q(g.host, 'af-status').textContent) &&
+ !/is back where it started/.test(q(g.host, 'af-status').textContent),
+ q(g.host, 'af-status').textContent.slice(0, 90));
+ g.ed.destroy();
+
+ // Destroyed mid-sweep. The walk back still has to happen -- the lens is
+ // real -- but nothing may poll a panel that has been removed.
+ g = await mk({ lens: true });
+ q(g.host, 'af-measure').click();
+ await pause(1600);
+ g.ed.destroy();
+ await pause(300);
+ const readsAtKill = g.seen.reads;
+ await pause(2500);
+ t('a destroyed editor does not go on reading the camera',
+ g.seen.reads === readsAtKill, readsAtKill + ' -> ' + g.seen.reads);
+ t('but the lens still comes home', g.seen.pos === 0, String(g.seen.pos));
+
+ // Leave Focus and come straight back. The old sweep is still walking the
+ // lens home; a new one starting now sends `far` against its `near`.
+ 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();
+ [...g.host.querySelectorAll('.re-seg button')].find((b) => b.textContent.trim() === 'Focus').click();
+ await pause(50);
+ q(g.host, 'af-measure').click();
+ // Sampling a position mid-walk proves nothing -- 2 on the way home and 2 on
+ // the way out look identical. What distinguishes them is ORDER: once the
+ // old sweep starts walking back, nothing may send `far` until the lens is
+ // home. So let it all settle, then read the trail.
+ for (let i = 0; i < 300; i++) {
+ await pause(100);
+ if (Date.now() - (g.seen.lastMove || 0) > 1500) break;
+ }
+ let home = true, clash = null;
+ g.seen.trail.forEach(function (step) {
+ const v = step.split('@')[0], at = Number(step.split('@')[1]);
+ if (v === 'near') home = false;
+ if (v === 'far' && !home && clash === null) clash = step;
+ if (at === 0) home = true;
+ });
+ t('a new sweep waits for the old one to bring the lens back',
+ clash === null, clash ? 'drove out at ' + clash : g.seen.trail.join(' '));
+ t('and the lens ends where it began', g.seen.pos === 0, String(g.seen.pos));
+ g.ed.destroy();
+
+ // Near and Far drive the same lens the sweep is driving.
+ g = await mk({ lens: true });
+ q(g.host, 'af-measure').click();
+ await pause(900);
+ t('the manual controls are held down for the sweep',
+ q(g.host, 'focus-near').disabled && q(g.host, 'focus-far').disabled,
+ 'near ' + q(g.host, 'focus-near').disabled + ', far ' + q(g.host, 'focus-far').disabled);
+ const movesBefore = g.seen.moves.length;
+ q(g.host, 'focus-far').dispatchEvent(new PointerEvent('pointerdown', { button: 0, bubbles: true }));
+ await pause(200);
+ q(g.host, 'focus-far').dispatchEvent(new PointerEvent('pointerup', { button: 0, bubbles: true }));
+ t('and holding one during a sweep moves nothing',
+ g.seen.moves.length === movesBefore,
+ movesBefore + ' -> ' + g.seen.moves.length);
+ g.ed.destroy();
+
} catch (e) {
t('the filter designer is handled', false, e.message);
}