From bf7d44899a9505fcbf9a62877581b8c26e74077e Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Fri, 23 Oct 2026 00:24:12 -0400 Subject: [PATCH 1/2] Camera: say why buttons are disabled, disable them during a scan; Edge glow threshold and Smoothing hints (#173) Calibrate / Check exposure and Check framing show their reason beside them and in their tooltip, as Scan does: the engine is down, open the output window, or choose a camera. While a scan runs, Show preview, Calibrate, Check framing and Skip an area are disabled with "The scan is using the camera". Edge glow's Sensitivity is now Edge threshold ("Higher: only strong edges"); same key, range and direction, so saved shows look the same. Number settings can carry a hint (the control's tooltip) and step names: Smoothing reads Off / Low / High, still saved as 0..2. Assisted-by: Claude Code:claude-opus-5-5 --- README.md | 2 +- frontend/index.html | 10 +++-- .../src/editor/cameraPanel.browser.test.ts | 40 +++++++++++++++++-- frontend/src/editor/cameraPanel.ts | 25 +++++++++++- frontend/src/editor/controls.test.ts | 19 +++++++++ frontend/src/editor/controls.ts | 3 +- frontend/src/editor/main.ts | 1 + frontend/src/effects/edgeglow.ts | 6 +-- frontend/src/effects/settingTypes.ts | 3 +- frontend/src/effects/types.ts | 5 ++- frontend/src/output/scanEdges.ts | 2 +- 11 files changed, 98 insertions(+), 18 deletions(-) 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..a4dab71 100644 --- a/frontend/src/editor/cameraPanel.browser.test.ts +++ b/frontend/src/editor/cameraPanel.browser.test.ts @@ -113,14 +113,48 @@ 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("saves a scan setting as it's changed", () => { diff --git a/frontend/src/editor/cameraPanel.ts b/frontend/src/editor/cameraPanel.ts index d99a2fb..5fc05b4 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,33 @@ 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 = checkFraming.disabled = reason !== null; + 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..528d794 100644 --- a/frontend/src/editor/main.ts +++ b/frontend/src/editor/main.ts @@ -964,6 +964,7 @@ 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; // 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; 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). From 320cc52737ec3842ca2bb2f83115df9a5c4301e5 Mon Sep 17 00:00:00 2001 From: Eric Dahl Date: Fri, 23 Oct 2026 08:16:02 -0400 Subject: [PATCH 2/2] Camera: follow the scan as it starts and ends; keep a busy button disabled (#173) The camera panel was updated only on status, which the engine doesn't send on scan start or end: its buttons stayed enabled into a scan and disabled after it. renderScanButton() now updates it, on status and on every scan message. A status arriving mid-calibrate no longer re-enables the busy button. Effect setting hints also reach screen readers (aria-description). Assisted-by: Claude Code:claude-opus-5-5 --- frontend/src/editor/cameraPanel.browser.test.ts | 12 ++++++++++++ frontend/src/editor/cameraPanel.ts | 3 ++- frontend/src/editor/main.ts | 7 +++++-- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/frontend/src/editor/cameraPanel.browser.test.ts b/frontend/src/editor/cameraPanel.browser.test.ts index a4dab71..dc52817 100644 --- a/frontend/src/editor/cameraPanel.browser.test.ts +++ b/frontend/src/editor/cameraPanel.browser.test.ts @@ -157,6 +157,18 @@ describe("the Camera section", () => { 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", () => { const { engine, calls } = fakeEngine(); mountCameraPanel({ engine, notice: () => {} }); diff --git a/frontend/src/editor/cameraPanel.ts b/frontend/src/editor/cameraPanel.ts index 5fc05b4..846aa98 100644 --- a/frontend/src/editor/cameraPanel.ts +++ b/frontend/src/editor/cameraPanel.ts @@ -218,7 +218,8 @@ export function mountCameraPanel(deps: CameraPanelDeps): CameraPanel { : !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 = checkFraming.disabled = reason !== 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 ?? ""; diff --git a/frontend/src/editor/main.ts b/frontend/src/editor/main.ts index 528d794..7ba9bf2 100644 --- a/frontend/src/editor/main.ts +++ b/frontend/src/editor/main.ts @@ -964,7 +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; + 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; @@ -1092,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() { @@ -1112,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.