diff --git a/doc/automation.md b/doc/automation.md index fcbf8a4936..fbe956c8bf 100644 --- a/doc/automation.md +++ b/doc/automation.md @@ -4,7 +4,8 @@ OrcaSlicer ships an **opt-in, localhost-only JSON-RPC server** that lets externa scripts introspect, drive, and screenshot the running OrcaSlicer GUI. It is built for end-to-end testing and automation: a script can enumerate the live widget tree, click buttons, type text, send keyboard shortcuts, wait for UI state, query -high-level application state, and capture both window and 3D-viewport images. +high-level application state, and capture window images (the on-screen capture +includes the 3D viewport). This document is the protocol reference. It describes activation, the transport, the JSON-RPC envelope, every method, the unified node shape, the target/locator @@ -109,8 +110,7 @@ Returns server identity and the list of supported methods. Takes no parameters. "protocol": "2.0", "capabilities": [ "tree.dump", "tree.find", "widget.get", "input.click", "input.type", - "input.key", "sync.wait_for", "app.state", "screenshot.window", - "screenshot.viewport3d" + "input.key", "sync.wait_for", "app.state", "screenshot.window" ] } ``` @@ -295,28 +295,10 @@ method reads from the screen.) - **HiDPI:** the reported `width`/`height` come from the window's logical client size, while the screen framebuffer is in physical pixels. On per-monitor-DPI displays the two can differ; the capture may be cropped or scaled relative to the logical size. -- For a clean, occlusion-independent, arbitrary-resolution render of the 3D scene - (including when the 3D tab is not the visible view), use - [`screenshot.viewport3d`](#screenshotviewport3d) instead. - -### `screenshot.viewport3d` - -Render a 3D plate offscreen and return it as a PNG. Unlike `screenshot.window`, this -renders into an offscreen framebuffer, so it is independent of window size and -occlusion, works even when the 3D tab is hidden, and supports arbitrary output -resolution — making it the right choice for clean, deterministic captures. - -**Params (all optional):** - -| Param | Type | Default | Meaning | -|---|---|---|---| -| `plate` | int | active plate | Plate index to render. | -| `width` | int | `800` | Output width in pixels. | -| `height` | int | `600` | Output height in pixels. | - -**Result:** `{ "png_base64": "", "width": , "height": }`. - -**Errors:** `1005` on failure. +- Because the capture is the live on-screen image, the 3D content reflects the + **current view**: the model in the 3D editor, or the gcode toolpaths in Preview + after a slice. There is no separate offscreen 3D-render method — the window + capture already includes whatever the GL canvas is showing. --- @@ -494,8 +476,7 @@ Consequences and conventions: - **`screenshot.window` reads the screen.** It captures the on-screen, DWM-composited framebuffer, so the target window must be visible and unobscured, and the result is in physical pixels (see HiDPI caveat under [`screenshot.window`](#screenshotwindow)). - For occlusion-independent 3D captures use - [`screenshot.viewport3d`](#screenshotviewport3d). + The capture includes the GL 3D viewport as currently shown (model or toolpaths). - **Single-client / serialized.** v1 handles one request at a time; issue requests sequentially from a single client. - **GUI-thread marshaling.** Every backend call is marshaled onto the GUI thread @@ -517,14 +498,14 @@ orca.click({"id": "btn_slice"}) # start slicing the plate orca.wait_for({"id": "btn_export"}, # wait until slicing finishes state="enabled", timeout_ms=180000) -png = orca.screenshot_3d(width=1024, height=768) # render the 3D viewport -with open("preview_3d.png", "wb") as f: +png = orca.screenshot() # on-screen capture (incl. 3D view) +with open("window.png", "wb") as f: f.write(png) ``` For a full, runnable end-to-end example — launching OrcaSlicer with the automation -flags, loading a model, slicing, waiting for completion, and saving both a window -PNG and a 3D PNG — see `tools/automation/example_slice.py`. +flags, loading a model, slicing, waiting for completion, and saving a window PNG — +see `tools/automation/example_slice.py`. --- diff --git a/src/slic3r/GUI/Automation/IUiBackend.hpp b/src/slic3r/GUI/Automation/IUiBackend.hpp index b708d12c8a..27ff9501eb 100644 --- a/src/slic3r/GUI/Automation/IUiBackend.hpp +++ b/src/slic3r/GUI/Automation/IUiBackend.hpp @@ -92,11 +92,9 @@ public: // Send key chords (e.g. ctrl+s) to the focused window. virtual bool send_keys(const std::vector& chords) = 0; - // Screenshots. target == nullptr => main frame. + // Screenshot. target == nullptr => main frame. Captured from the on-screen + // composited framebuffer, so it includes the GL viewport and ImGui overlays. virtual PngImage screenshot_window(const UiNode* target) = 0; - virtual PngImage screenshot_viewport3d(std::optional plate, - std::optional width, - std::optional height) = 0; }; }}} // namespace Slic3r::GUI::Automation diff --git a/src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp b/src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp index a3269f9a6c..356664f7dc 100644 --- a/src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp +++ b/src/slic3r/GUI/Automation/JsonRpcDispatcher.cpp @@ -155,12 +155,6 @@ std::string base64_encode(const std::vector& data) { return out; } -std::optional opt_int(const nlohmann::json& p, const char* key) { - if (p.is_object() && p.contains(key) && p.at(key).is_number_integer()) - return p.at(key).get(); - return std::nullopt; -} - nlohmann::json image_to_json(const PngImage& img) { if (img.png.empty()) throw AutomationError(kErrScreenshotFail, "screenshot produced no data"); @@ -174,8 +168,7 @@ nlohmann::json JsonRpcDispatcher::m_version(const nlohmann::json&) { {"protocol", "2.0"}, {"capabilities", nlohmann::json::array({ "tree.dump","tree.find","widget.get","input.click","input.type", - "input.key","sync.wait_for","app.state","screenshot.window", - "screenshot.viewport3d" })} }; + "input.key","sync.wait_for","app.state","screenshot.window" })} }; } nlohmann::json JsonRpcDispatcher::dispatch(const nlohmann::json& request) { @@ -200,7 +193,6 @@ nlohmann::json JsonRpcDispatcher::dispatch(const nlohmann::json& request) { if (method == "sync.wait_for") return make_result(id, m_sync_wait_for(params)); if (method == "app.state") return make_result(id, m_app_state(params)); if (method == "screenshot.window") return make_result(id, m_screenshot_window(params)); - if (method == "screenshot.viewport3d") return make_result(id, m_screenshot_viewport3d(params)); return make_error(id, kMethodNotFound, "unknown method: " + method); } catch (const AutomationError& e) { return make_error(id, e.code, e.what()); @@ -350,9 +342,4 @@ nlohmann::json JsonRpcDispatcher::m_screenshot_window(const nlohmann::json& para return image_to_json(m_backend.screenshot_window(target_ptr)); } -nlohmann::json JsonRpcDispatcher::m_screenshot_viewport3d(const nlohmann::json& params) { - return image_to_json(m_backend.screenshot_viewport3d( - opt_int(params, "plate"), opt_int(params, "width"), opt_int(params, "height"))); -} - }}} // namespace diff --git a/src/slic3r/GUI/Automation/JsonRpcDispatcher.hpp b/src/slic3r/GUI/Automation/JsonRpcDispatcher.hpp index 1952e37fd1..d95b5c6b6a 100644 --- a/src/slic3r/GUI/Automation/JsonRpcDispatcher.hpp +++ b/src/slic3r/GUI/Automation/JsonRpcDispatcher.hpp @@ -47,7 +47,6 @@ private: nlohmann::json m_sync_wait_for(const nlohmann::json& params); nlohmann::json m_app_state(const nlohmann::json& params); nlohmann::json m_screenshot_window(const nlohmann::json& params); - nlohmann::json m_screenshot_viewport3d(const nlohmann::json& params); // Resolve a unique, actionable (enabled+visible) node from params["target"]. // Throws kErrNotFound (missing/ambiguous) or kErrNotActionable (disabled/hidden). diff --git a/src/slic3r/GUI/Automation/WxUiBackend.cpp b/src/slic3r/GUI/Automation/WxUiBackend.cpp index 12b83012c7..68ff154b88 100644 --- a/src/slic3r/GUI/Automation/WxUiBackend.cpp +++ b/src/slic3r/GUI/Automation/WxUiBackend.cpp @@ -5,9 +5,8 @@ #include "slic3r/GUI/GUI_App.hpp" #include "slic3r/GUI/MainFrame.hpp" #include "slic3r/GUI/Plater.hpp" -#include "slic3r/GUI/GLCanvas3D.hpp" +#include "slic3r/GUI/GLCanvas3D.hpp" // get_current_canvas3D() for app.state #include "libslic3r/Model.hpp" -#include "libslic3r/GCode/ThumbnailData.hpp" #include #include @@ -270,21 +269,6 @@ PngImage wximage_to_png(const wxImage& image) { return out; } -// RGBA ThumbnailData -> wxImage (mirrors GLCanvas3D::debug_output_thumbnail — -// note the vertical flip GL rows require). -wxImage thumbnail_to_wximage(const ThumbnailData& td) { - wxImage image((int)td.width, (int)td.height); - image.InitAlpha(); - for (unsigned int r = 0; r < td.height; ++r) { - unsigned int rr = (td.height - 1 - r) * td.width; - for (unsigned int c = 0; c < td.width; ++c) { - const unsigned char* px = td.pixels.data() + 4 * (rr + c); - image.SetRGB((int)c, (int)r, px[0], px[1], px[2]); - image.SetAlpha((int)c, (int)r, px[3]); - } - } - return image; -} } // namespace PngImage WxUiBackend::screenshot_window(const UiNode* target) { @@ -319,32 +303,4 @@ PngImage WxUiBackend::screenshot_window(const UiNode* target) { }); } -PngImage WxUiBackend::screenshot_viewport3d(std::optional plate, - std::optional width, - std::optional height) { - return run_on_gui(m_gui_timeout_ms, [&]() -> PngImage { - Plater* p = wxGetApp().plater(); - if (p == nullptr) - throw AutomationError(kErrScreenshotFail, "no plater"); - GLCanvas3D* canvas = p->get_current_canvas3D(); - if (canvas == nullptr) - throw AutomationError(kErrScreenshotFail, "no 3D canvas"); - const unsigned int w = width ? (unsigned)*width : 800u; - const unsigned int h = height ? (unsigned)*height : 600u; - - // Render the active plate's 3D scene into an offscreen RGBA buffer. - // render_thumbnail makes the canvas's GL context current itself. The - // pixel size is governed by w/h; `sizes` stays empty as elsewhere. - // Fields: {sizes, printable_only, parts_only, show_bed, transparent_background, plate_id}. - const int plate_id = plate ? *plate : 0; // v1: default active plate - const ThumbnailsParams params{ {}, false, false, true, false, plate_id }; - - ThumbnailData data; - canvas->render_thumbnail(data, w, h, params, Camera::EType::Ortho); - if (!data.is_valid()) - throw AutomationError(kErrScreenshotFail, "thumbnail render failed"); - return wximage_to_png(thumbnail_to_wximage(data)); - }); -} - }}} // namespace Slic3r::GUI::Automation diff --git a/src/slic3r/GUI/Automation/WxUiBackend.hpp b/src/slic3r/GUI/Automation/WxUiBackend.hpp index 3dc6aa056e..6239cfbfae 100644 --- a/src/slic3r/GUI/Automation/WxUiBackend.hpp +++ b/src/slic3r/GUI/Automation/WxUiBackend.hpp @@ -19,8 +19,6 @@ public: bool type_text(const std::string& text) override; bool send_keys(const std::vector& chords) override; PngImage screenshot_window(const UiNode* target) override; - PngImage screenshot_viewport3d(std::optional plate, std::optional width, - std::optional height) override; private: int m_gui_timeout_ms; diff --git a/tests/automation/MockUiBackend.hpp b/tests/automation/MockUiBackend.hpp index b159fa4370..edf9408011 100644 --- a/tests/automation/MockUiBackend.hpp +++ b/tests/automation/MockUiBackend.hpp @@ -18,7 +18,6 @@ public: std::vector typed_text; std::vector> sent_keys; int screenshot_window_count = 0; - int screenshot_viewport_count = 0; // Canned outputs (set by tests). UiNode tree; // default tree for dump_tree @@ -50,10 +49,6 @@ public: PngImage screenshot_window(const UiNode*) override { ++screenshot_window_count; return canned_png; } - PngImage screenshot_viewport3d(std::optional, std::optional, - std::optional) override { - ++screenshot_viewport_count; return canned_png; - } }; }}} // namespace diff --git a/tests/automation/test_dispatcher.cpp b/tests/automation/test_dispatcher.cpp index 963d604ef4..d074248300 100644 --- a/tests/automation/test_dispatcher.cpp +++ b/tests/automation/test_dispatcher.cpp @@ -162,15 +162,6 @@ TEST_CASE("screenshot.window returns base64 + dims", "[automation][rpc]") { CHECK_FALSE(resp.at("result").at("png_base64").get().empty()); } -TEST_CASE("screenshot.viewport3d returns base64 + dims", "[automation][rpc]") { - MockUiBackend mock; - JsonRpcDispatcher d(mock); - const json resp = d.dispatch({{"jsonrpc","2.0"},{"id",3},{"method","screenshot.viewport3d"}, - {"params",{{"width",256},{"height",256}}}}); - CHECK(mock.screenshot_viewport_count == 1); - CHECK(resp.at("result").at("png_base64").is_string()); -} - TEST_CASE("sync.wait_for succeeds once the condition holds", "[automation][rpc]") { MockUiBackend mock; // First 2 polls: btn disabled. 3rd poll: enabled. diff --git a/tools/automation/example_slice.py b/tools/automation/example_slice.py index 9dcfd77e45..999bfbfc48 100644 --- a/tools/automation/example_slice.py +++ b/tools/automation/example_slice.py @@ -1,5 +1,5 @@ """End-to-end smoke test: launch OrcaSlicer with the automation server, load a -model, slice it, wait for completion, and save both a window PNG and a 3D PNG. +model, slice it, wait for completion, and save a window PNG. Run: python example_slice.py --orca /path/to/OrcaSlicer --model /path/to/cube.stl @@ -56,11 +56,12 @@ def main() -> int: orca.wait_for({"id": "btn_export"}, state="enabled", timeout_ms=180000, poll_ms=500) + # The window screenshot is captured from the on-screen composited + # framebuffer, so it already includes the 3D viewport (model in the + # editor, or toolpaths in Preview after slicing). with open("window.png", "wb") as f: f.write(orca.screenshot()) - with open("preview_3d.png", "wb") as f: - f.write(orca.screenshot_3d(width=1920, height=1080)) - print("wrote window.png and preview_3d.png") + print("wrote window.png") return 0 finally: proc.terminate() diff --git a/tools/automation/orca_automation.py b/tools/automation/orca_automation.py index fdc696df8d..ad2de899ef 100644 --- a/tools/automation/orca_automation.py +++ b/tools/automation/orca_automation.py @@ -6,8 +6,8 @@ Usage: print(orca.version()) orca.click({"id": "btn_slice"}) orca.wait_for({"id": "btn_export"}, state="enabled", timeout_ms=120000) - png = orca.screenshot_3d(width=1024, height=768) - open("preview.png", "wb").write(png) + png = orca.screenshot() + open("window.png", "wb").write(png) """ from __future__ import annotations import base64 @@ -94,18 +94,8 @@ class OrcaClient: return self._call("app.state") def screenshot(self, target: Optional[dict] = None) -> bytes: + """Capture a window as a PNG, exactly as composited on screen (includes the + GL 3D viewport and ImGui overlays). Defaults to the main frame.""" params = {"target": target} if target is not None else None result = self._call("screenshot.window", params) return base64.b64decode(result["png_base64"]) - - def screenshot_3d(self, plate: Optional[int] = None, - width: Optional[int] = None, height: Optional[int] = None) -> bytes: - params: dict = {} - if plate is not None: - params["plate"] = plate - if width is not None: - params["width"] = width - if height is not None: - params["height"] = height - result = self._call("screenshot.viewport3d", params or None) - return base64.b64decode(result["png_base64"])