diff --git a/README.md b/README.md
index 7ea5a3b..74d9c8b 100644
--- a/README.md
+++ b/README.md
@@ -242,7 +242,7 @@ Pick an effect for the selected surface in the surface panel; its controls appea
| **Outline trace** | A glowing line around the surface's edge, optionally chasing round it | Color, Line width (1 to 40 px), Glow, Chase, Speed (0 to 2 laps/s), Segments (1 to 8), React to sound |
| **Noise flow** | Slowly drifting cloud-like texture between two colors | Color, Second color, Scale, Speed, Contrast, Brightness, React to sound |
| **Tint (scan)** | Recolors the real object while keeping its texture (wood grain, fabric) visible | Color, Texture, Scan gain |
-| **Edge glow (scan)** | Glowing lines along the real edges the scan sees: panel grooves, grain, folds | Color, Sensitivity, Line width (1 to 8 px), Smoothing (0 to 2), Base light, Pulse |
+| **Edge glow (scan)** | Glowing lines along the real edges the scan sees: panel grooves, grain, folds | Color, Edge threshold (higher: only strong edges), Line width (1 to 8 px), Smoothing (Off, Low, High: ignores speckle in the scan), Base light, Pulse |
| **Posterize (scan)** | Flattens the real surface into a few bands of color, like a screen print | Bands (2 to 8), Dark color, Light color, Scan gain |
| **Text** | Your text, as large as fits inside the surface (or corner-pinned to it), several lines allowed | Text, Color, Background, Font (Sans, Serif, Mono), Align, Fit (Fit inside, Map to corners), Motion (None, Scroll, Pulse), Speed, React to sound |
| **Image / video** | An uploaded image or video, clipped to the surface's outline | Image or video (file); Fit: Cover, Stretch, Map to corners, Contain, Original size, Tile; Zoom, Pan, Rotate, Flip, Background; Video start and speed, Video sound and Volume |
diff --git a/frontend/index.html b/frontend/index.html
index 6e04b76..b4901c0 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -303,9 +303,10 @@
Surface
Camera
-
-
-
+
+
+
+
@@ -337,9 +338,10 @@
Surface
-
+
+
Test frame
diff --git a/frontend/src/editor/cameraPanel.browser.test.ts b/frontend/src/editor/cameraPanel.browser.test.ts
index ecdf55a..dc52817 100644
--- a/frontend/src/editor/cameraPanel.browser.test.ts
+++ b/frontend/src/editor/cameraPanel.browser.test.ts
@@ -113,14 +113,60 @@ describe("the Camera section", () => {
expect(document.querySelector("#framing-overlay polygon")!.getAttribute("points")).toBe("0.1,0.1 0.9,0.1 0.9,0.9");
});
- it("can't check framing or calibrate without the output window, or check framing during a scan", () => {
+ it("says beside them why calibrate and the framing check can't run without the output window (#173)", () => {
const { engine } = fakeEngine();
const panel = mountCameraPanel({ engine, notice: () => {} });
panel.update({ ...status(AC410), output_connected: false }, false);
- expect($("calibrate").disabled).toBe(true);
- panel.update(status(AC410), true);
+ for (const id of ["calibrate", "check-framing"]) {
+ expect($(id).disabled).toBe(true);
+ expect($(id).title).toBe("Open the output window first");
+ }
+ expect($("camera-reason").hidden).toBe(false);
+ expect($("camera-reason").textContent).toBe("Open the output window first");
+ panel.update(status(AC410), false);
expect($("calibrate").disabled).toBe(false);
+ expect($("camera-reason").hidden).toBe(true);
+ expect($("check-framing").title).toMatch(/^Shows white, then black/); // its description is back
+ });
+
+ it("says to choose a camera when none is, and to start the engine when it's down (#173)", () => {
+ const { engine } = fakeEngine();
+ const panel = mountCameraPanel({ engine, notice: () => {} });
+ const none = status(AC410);
+ panel.update({ ...none, camera: { ...none.camera, selected: null } }, false);
+ expect($("calibrate").disabled).toBe(true);
+ expect($("camera-reason").textContent).toBe("Choose a camera first");
+ panel.update(null, false);
expect($("check-framing").disabled).toBe(true);
+ expect($("camera-reason").textContent).toBe("The engine isn't running: start it with make dev");
+ });
+
+ it("disables what needs the camera while a scan runs, and says why (#173)", () => {
+ const { engine } = fakeEngine();
+ const panel = mountCameraPanel({ engine, notice: () => {} });
+ panel.update(status(AC410), true);
+ for (const id of ["preview-toggle", "calibrate", "check-framing", "mask-draw"]) {
+ expect($(id).disabled, id).toBe(true);
+ expect($(id).title, id).toBe("The scan is using the camera");
+ }
+ expect($("camera-reason").textContent).toBe("The scan is using the camera");
+ expect($("mask-reason").textContent).toBe("The scan is using the camera"); // beside Skip an area, further down
+ expect($("mask-clear").disabled).toBe(false); // doesn't need the camera
+ panel.update(status(AC410), false);
+ for (const id of ["preview-toggle", "calibrate", "check-framing", "mask-draw"]) expect($(id).disabled, id).toBe(false);
+ expect($("mask-reason").hidden).toBe(true);
+ });
+
+ it("keeps a busy button disabled when status arrives while it runs", async () => {
+ let finish!: (r: Response) => void;
+ const { engine } = fakeEngine({ calibrate: () => new Promise((r) => { finish = r; }) });
+ const panel = mountCameraPanel({ engine, notice: () => {} });
+ panel.update(status(AC410), false);
+ $("calibrate").click();
+ panel.update(status(AC410), false); // e.g. the output window's stats, every 2 s
+ expect($("calibrate").disabled).toBe(true);
+ finish(json({ exposure: 1200, gain: 3, p99: 201.4 }));
+ await vi.waitFor(() => expect($("calibrate").disabled).toBe(false));
});
it("saves a scan setting as it's changed", () => {
diff --git a/frontend/src/editor/cameraPanel.ts b/frontend/src/editor/cameraPanel.ts
index d99a2fb..846aa98 100644
--- a/frontend/src/editor/cameraPanel.ts
+++ b/frontend/src/editor/cameraPanel.ts
@@ -159,6 +159,7 @@ export function mountCameraPanel(deps: CameraPanelDeps): CameraPanel {
const checkFraming = $("check-framing");
const framingNote = $("framing-note");
const exposureNote = $("exposure-note");
+ const framingTip = checkFraming.title; // what it does; its reason replaces it while disabled
async function usingCamera(button: HTMLButtonElement, busyLabel: string, run: () => Promise) {
const label = button.textContent;
button.disabled = true;
@@ -202,13 +203,34 @@ export function mountCameraPanel(deps: CameraPanelDeps): CameraPanel {
const calibration = $("calibration");
const battery = $("battery");
const batteryLabel = $("battery-label");
+ const cameraReason = $("camera-reason");
+ const maskReason = $("mask-reason");
+ // A reason as the tooltip; none leaves the row's own tooltip showing (an empty title would hide it).
+ const tip = (el: HTMLElement, text: string | null) => (text ? (el.title = text) : el.removeAttribute("title"));
function render() {
const view = describeStatus(status);
calibration.textContent = view.calibration;
battery.textContent = view.battery?.replace("Battery ", "") ?? "";
battery.hidden = batteryLabel.hidden = view.battery === null;
- calibrate.disabled = !status?.output_connected || !status.camera.selected;
- checkFraming.disabled = calibrate.disabled || scanning;
+ // Why calibrate and the framing check can't run, beside them and in their tooltips, as Scan does (#173).
+ const scanReason = scanning ? "The scan is using the camera" : null;
+ const reason = scanning ? scanReason
+ : !status ? view.scanReason // the engine is down
+ : !status.output_connected ? "Open the output window first"
+ : !status.camera.selected ? "Choose a camera first" : null;
+ calibrate.disabled = reason !== null || !!calibrate.dataset.busy;
+ checkFraming.disabled = reason !== null || !!checkFraming.dataset.busy;
+ tip(calibrate, reason);
+ checkFraming.title = reason ?? framingTip;
+ cameraReason.textContent = reason ?? "";
+ cameraReason.hidden = !reason;
+ // The preview and drawing a skipped area (which starts it) need the camera too, but not the output.
+ const maskDrawButton = $("mask-draw");
+ previewToggle.disabled = maskDrawButton.disabled = scanning;
+ tip(previewToggle, scanReason);
+ tip(maskDrawButton, scanReason);
+ maskReason.textContent = scanReason ?? "";
+ maskReason.hidden = !scanning;
const sel = status?.hardware.cameras.find((c) => c.unique_id === status?.camera.selected);
const still = sel?.device_type === "still";
apertureRow.hidden = !still; // a webcam's lens has none to set
diff --git a/frontend/src/editor/controls.test.ts b/frontend/src/editor/controls.test.ts
index 711f126..4519286 100644
--- a/frontend/src/editor/controls.test.ts
+++ b/frontend/src/editor/controls.test.ts
@@ -73,4 +73,23 @@ describe("a setting's readout", () => {
it("is the bare value for settings without a unit", () => {
expect(readout(control("fill", "brightness"), 0.5)).toBe("0.5");
});
+
+ it("names Edge glow's Smoothing steps instead of 0, 1, 2", () => {
+ const smoothing = control("edgeglow", "smoothing");
+ expect([0, 1, 2].map((v) => readout(smoothing, v))).toEqual(["Off", "Low", "High"]);
+ expect(readout(smoothing, "2")).toBe("High"); // the slider's own string value
+ });
+});
+
+describe("Edge glow's settings (#173)", () => {
+ const controls = controlsFor(effectById("edgeglow"), {});
+ const byName = (name: string) => controls.find((c) => c.name === name)!;
+
+ it("calls its threshold what it is, says which way it goes, and keeps the saved key", () => {
+ expect(byName("threshold")).toMatchObject({ label: "Edge threshold", hint: "Higher: only strong edges", min: 0.01, max: 0.5 });
+ });
+
+ it("says what Smoothing does", () => {
+ expect(byName("smoothing")).toMatchObject({ hint: "Ignores speckle in the scan. Off: every edge, High: only clean lines" });
+ });
});
diff --git a/frontend/src/editor/controls.ts b/frontend/src/editor/controls.ts
index 60dd959..32117ba 100644
--- a/frontend/src/editor/controls.ts
+++ b/frontend/src/editor/controls.ts
@@ -15,8 +15,9 @@ export function parseControlValue(kind: Control["kind"], raw: string): string |
return kind === "range" ? Number(raw) : raw;
}
-/** A range setting's value as its readout shows it, with its unit: "6 px", "90°". */
+/** A range setting's value as its readout shows it, with its unit or step name: "6 px", "90°", "Low". */
export function readout(c: Control, value: number | string): string {
+ if (c.kind === "range" && c.steps) return c.steps[Math.round((Number(value) - c.min) / c.step)] ?? String(value);
if (c.kind !== "range" || !c.unit) return String(value);
return c.unit === "°" ? `${value}°` : `${value} ${c.unit}`;
}
diff --git a/frontend/src/editor/main.ts b/frontend/src/editor/main.ts
index 1af0197..7ba9bf2 100644
--- a/frontend/src/editor/main.ts
+++ b/frontend/src/editor/main.ts
@@ -964,6 +964,10 @@ function renderPanel() {
patchSurface(surface.id, { params: { [c.name]: parseControlValue(c.kind, input.value) } });
});
row.append(Object.assign(document.createElement("span"), { textContent: c.label }), readout, input);
+ if (c.kind === "range" && c.hint) {
+ row.title = c.hint;
+ input.setAttribute("aria-description", c.hint); // a screen reader may skip the row's title
+ }
// Shown below while sound is off (see the end of renderPanel); after the slider, so the label names it.
if (c.name === "react") row.append(Object.assign(soundOffNote(), { hidden: true }));
return row;
@@ -1091,6 +1095,7 @@ function renderScanButton() {
scan.title = reason ?? (scanState.running ? "Stop the scan" : "Project the scan patterns and find the surfaces");
scanReason.textContent = reason ?? "";
scanReason.hidden = !reason;
+ cameraPanel.update(status, scanState.running); // on scan start and end too, not only on the next status (#173)
}
function render() {
@@ -1111,7 +1116,6 @@ function render() {
projectSaveName.value = status.project.name;
}
syncOptions(cameraSelect, cameraOptions(status));
- cameraPanel.update(status, scanState.running);
}
// Sound: the output window listens; the editor switches it and shows its meter.
diff --git a/frontend/src/effects/edgeglow.ts b/frontend/src/effects/edgeglow.ts
index 0c1c27c..622c861 100644
--- a/frontend/src/effects/edgeglow.ts
+++ b/frontend/src/effects/edgeglow.ts
@@ -6,10 +6,10 @@ export const edgeglow: Effect = {
name: "Edge glow (scan)",
params: [
{ name: "glowColor", label: "Color", type: "color", default: "#22d3ee" },
- { name: "threshold", label: "Sensitivity", type: "number", default: 0.08, min: 0.01, max: 0.5, step: 0.01 },
+ { name: "threshold", label: "Edge threshold", type: "number", default: 0.08, min: 0.01, max: 0.5, step: 0.01, hint: "Higher: only strong edges" },
{ name: "spread", label: "Line width", type: "number", default: 2, min: 1, max: 8, step: 1, unit: "px" },
- // Ignores stray pixels and fine speckle in the scan (0: every last edge, as it was).
- { name: "smoothing", label: "Smoothing", type: "number", default: 1, min: 0, max: 2, step: 1 },
+ { name: "smoothing", label: "Smoothing", type: "number", default: 1, min: 0, max: 2, step: 1, steps: ["Off", "Low", "High"],
+ hint: "Ignores speckle in the scan. Off: every edge, High: only clean lines" },
{ name: "base", label: "Base light", type: "number", default: 0.1, min: 0, max: 1, step: 0.01 },
{ name: "pulse", label: "Pulse", type: "number", default: 0, min: 0, max: 1, step: 0.01 },
],
diff --git a/frontend/src/effects/settingTypes.ts b/frontend/src/effects/settingTypes.ts
index a5f127c..00c8939 100644
--- a/frontend/src/effects/settingTypes.ts
+++ b/frontend/src/effects/settingTypes.ts
@@ -8,7 +8,7 @@ import type { ParamSchema, UniformValue } from "./types";
/** An editor control for one setting, showing the surface's saved value or the default. */
export type Control =
| { name: string; label: string; kind: "color"; value: string }
- | { name: string; label: string; kind: "range"; value: number; min: number; max: number; step: number; unit?: string }
+ | { name: string; label: string; kind: "range"; value: number; min: number; max: number; step: number; unit?: string; steps?: string[]; hint?: string }
| { name: string; label: string; kind: "select"; value: string; options: { value: string; label: string }[] }
| { name: string; label: string; kind: "media"; value: string }
| { name: string; label: string; kind: "text"; value: string };
@@ -61,6 +61,7 @@ export const SETTING_TYPES: { [K in Kind]: SettingType } = {
control: (p, v) => ({
name: p.name, label: p.label, kind: "range", value: typeof v === "number" ? v : p.default,
min: p.min ?? 0, max: p.max ?? 1, step: p.step ?? 0.01, ...(p.unit ? { unit: p.unit } : {}),
+ ...(p.steps ? { steps: p.steps } : {}), ...(p.hint ? { hint: p.hint } : {}),
}),
validate: (p) =>
p.default < (p.min ?? -Infinity) || p.default > (p.max ?? Infinity)
diff --git a/frontend/src/effects/types.ts b/frontend/src/effects/types.ts
index 1ca68ca..9ba0956 100644
--- a/frontend/src/effects/types.ts
+++ b/frontend/src/effects/types.ts
@@ -6,8 +6,9 @@ import { textKey, type TextStyle } from "./textTexture";
export type ParamSchema =
| { name: string; label: string; type: "color"; default: string }
- // `unit` is shown after the value in the editor's readout ("6 px", "90°").
- | { name: string; label: string; type: "number"; default: number; min?: number; max?: number; step?: number; unit?: string }
+ // `unit` is shown after the value in the editor's readout ("6 px", "90°"); `steps` names each step
+ // instead ("Off", "Low", "High"); `hint` is the control's one-line tooltip.
+ | { name: string; label: string; type: "number"; default: number; min?: number; max?: number; step?: number; unit?: string; steps?: string[]; hint?: string }
// One of a few named options; the shader gets the option's index as a float.
| { name: string; label: string; type: "choice"; default: string; options: { value: string; label: string }[] }
// An uploaded image or video (its URL). The shader gets `sampler2D u_` and its pixel size `vec2 u_Size`.
diff --git a/frontend/src/output/scanEdges.ts b/frontend/src/output/scanEdges.ts
index 3dff6b7..73ebc73 100644
--- a/frontend/src/output/scanEdges.ts
+++ b/frontend/src/output/scanEdges.ts
@@ -3,7 +3,7 @@ import { linkProgram } from "../effects/compile";
// The scan image's edge strength (#158), computed once per scan, line width and size instead of by
// every Edge glow surface on every frame (8 scan reads per pixel, 11 surfaces: it slowed the rig).
// Strength is the Sobel magnitude of the scan's brightness, 0..~5.7; it's stored as sqrt(m / MAX) in
-// 16 bits over two 8-bit channels: 8 bits alone left steps that Sensitivity's sharp threshold made
+// 16 bits over two 8-bit channels: 8 bits alone left steps that Edge threshold's sharp cutoff made
// visible, and the square root keeps low strengths (where those thresholds sit) precise.
// `scanEdgeAt()` in the effect preamble decodes it (keep the two in step).