From 4f8a0d133f3819c9d54da552e8a13edbe178983b Mon Sep 17 00:00:00 2001 From: Tommaso Bianchi Date: Tue, 30 Jun 2026 15:32:51 +0200 Subject: [PATCH] Add MCP control surface (slice 1): socket server + describe/extrude MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Predispose the Design tab to run under an external MCP agent. New McpControl.{cpp,hpp} embeds a line-delimited JSON-RPC 2.0 server over a Unix domain socket, off unless env SNAPORCA_MCP is set. Requests are marshalled onto the wx main thread and run through the SAME CadDocument kernel the GUI uses, via two thin DesignPanel hooks (mcp_doc / mcp_after_change) — no parallel engine. Slice-1 methods: describe_tools (introspection -> the bridge builds tool schemas), describe_scene (feature tree + per-body bounding boxes), and extrude (centred rectangle sketch -> new solid). Every op returns its full post-state. POSIX-only; Windows compiles to a no-op. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q --- src/slic3r/CMakeLists.txt | 2 + src/slic3r/GUI/DesignPanel.hpp | 5 + src/slic3r/GUI/MainFrame.cpp | 2 + src/slic3r/GUI/McpControl.cpp | 264 +++++++++++++++++++++++++++++++++ src/slic3r/GUI/McpControl.hpp | 24 +++ 5 files changed, 297 insertions(+) create mode 100644 src/slic3r/GUI/McpControl.cpp create mode 100644 src/slic3r/GUI/McpControl.hpp diff --git a/src/slic3r/CMakeLists.txt b/src/slic3r/CMakeLists.txt index aaf37d39b3..c6232562ba 100644 --- a/src/slic3r/CMakeLists.txt +++ b/src/slic3r/CMakeLists.txt @@ -109,6 +109,8 @@ set(SLIC3R_GUI_SOURCES GUI/DownloadProgressDialog.hpp GUI/DesignPanel.cpp GUI/DesignPanel.hpp + GUI/McpControl.cpp + GUI/McpControl.hpp GUI/DesignCanvas.cpp GUI/DesignCanvas.hpp GUI/DesignSketchTool.cpp diff --git a/src/slic3r/GUI/DesignPanel.hpp b/src/slic3r/GUI/DesignPanel.hpp index 60f52af58e..a54219b4f5 100644 --- a/src/slic3r/GUI/DesignPanel.hpp +++ b/src/slic3r/GUI/DesignPanel.hpp @@ -37,6 +37,11 @@ public: explicit DesignPanel(wxWindow* parent); void on_tab_shown(); // re-sync bed to the active printer when the Design tab is activated + // MCP control hooks: let the external control server (McpControl.cpp) drive and + // perceive the SAME kernel the GUI uses. Called only on the wx main thread. + CadDocument& mcp_doc() { return m_doc; } // live document (read + mutate) + void mcp_after_change() { after_tree_edit(true); } // refresh tree + viewport + status + private: enum class Tool { None, Sketch, Extrude, Dressup, Hole, Thread, Shell, Revolve, Sweep, Pattern, Plane, Loft, Draft, Boolean, Cut, Insert }; // Plane tool: which datum reference the next solid pick fills (declared early so the diff --git a/src/slic3r/GUI/MainFrame.cpp b/src/slic3r/GUI/MainFrame.cpp index 543d665a71..b591295ab7 100644 --- a/src/slic3r/GUI/MainFrame.cpp +++ b/src/slic3r/GUI/MainFrame.cpp @@ -38,6 +38,7 @@ #include "GLCanvas3D.hpp" #include "Plater.hpp" #include "DesignPanel.hpp" +#include "McpControl.hpp" #include "WebViewDialog.hpp" #include "../Utils/Process.hpp" #include "format.hpp" @@ -1329,6 +1330,7 @@ void MainFrame::init_tabpanel() { // DesignCanvas reads wxGetApp().plater()->config() at construction time. wxGetApp().plater_ = m_plater; m_design_panel = new DesignPanel(this); + start_mcp_control_if_enabled(); // opens the MCP socket iff SNAPORCA_MCP is set m_plater->SetBackgroundColour(*wxWHITE); m_plater->Hide(); diff --git a/src/slic3r/GUI/McpControl.cpp b/src/slic3r/GUI/McpControl.cpp new file mode 100644 index 0000000000..f7ffa99deb --- /dev/null +++ b/src/slic3r/GUI/McpControl.cpp @@ -0,0 +1,264 @@ +#include "McpControl.hpp" + +#ifndef _WIN32 // POSIX Unix-domain-socket transport only (slice 1) + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include // OCCT base error (not a std::exception) + +#include "GUI_App.hpp" +#include "MainFrame.hpp" +#include "DesignPanel.hpp" + +#include "libslic3r/CadDocument.hpp" +#include "libslic3r/SketchEngine.hpp" +#include "libslic3r/TriangleMesh.hpp" +#include "libslic3r/BoundingBox.hpp" + +using json = nlohmann::json; + +namespace Slic3r { namespace GUI { + +namespace { + +const char* feature_type_name(CadFeatureType t) +{ + switch (t) { + case CadFeatureType::Sketch: return "Sketch"; + case CadFeatureType::Extrude: return "Extrude"; + case CadFeatureType::Fillet: return "Fillet"; + case CadFeatureType::Chamfer: return "Chamfer"; + case CadFeatureType::Hole: return "Hole"; + case CadFeatureType::Thread: return "Thread"; + case CadFeatureType::Shell: return "Shell"; + case CadFeatureType::Revolve: return "Revolve"; + case CadFeatureType::Sweep: return "Sweep"; + case CadFeatureType::Pattern: return "Pattern"; + case CadFeatureType::Plane: return "Plane"; + case CadFeatureType::Loft: return "Loft"; + case CadFeatureType::Draft: return "Draft"; + case CadFeatureType::Import: return "Import"; + case CadFeatureType::Boolean: return "Boolean"; + case CadFeatureType::Cut: return "Cut"; + } + return "Unknown"; +} + +// --- JSON-RPC envelope helpers ------------------------------------------------- +std::string rpc_result(const json& id, const json& result) +{ + return json{{"jsonrpc", "2.0"}, {"id", id}, {"result", result}}.dump(); +} +std::string rpc_error(const json& id, int code, const std::string& msg) +{ + return json{{"jsonrpc", "2.0"}, {"id", id}, + {"error", {{"code", code}, {"message", msg}}}}.dump(); +} + +// --- the three slice-1 methods (run on the wx MAIN thread) --------------------- + +json describe_tools() +{ + // Hand-written descriptor for slice 1. The bridge turns this into MCP tool + // schemas; later slices grow this list (ideally from the kernel directly). + return json{ + {"app", "SnapOrca CAD"}, + {"protocol", "jsonrpc-2.0"}, + {"slice", 1}, + {"tools", json::array({ + json{{"name", "describe_tools"}, {"summary", "List callable tools and their parameters."}, + {"params", json::array()}}, + json{{"name", "describe_scene"}, {"summary", "Feature tree + per-body bounding boxes of the Design document."}, + {"params", json::array()}}, + json{{"name", "extrude"}, {"summary", "Create a new solid: a centred rectangle sketch extruded to a depth."}, + {"params", json::array({ + json{{"name", "width"}, {"type", "number"}, {"unit", "mm"}, {"default", 20}, {"min", 0.01}}, + json{{"name", "height"}, {"type", "number"}, {"unit", "mm"}, {"default", 20}, {"min", 0.01}}, + json{{"name", "distance"}, {"type", "number"}, {"unit", "mm"}, {"default", 10}, {"min", 0.01}}, + json{{"name", "plane"}, {"type", "string"}, {"enum", json::array({"XY", "XZ", "YZ"})}, {"default", "XY"}}, + })}}, + })}, + }; +} + +json describe_scene(DesignPanel* panel) +{ + CadDocument& doc = panel->mcp_doc(); + + json features = json::array(); + for (size_t i = 0; i < doc.features.size(); ++i) { + const CadFeature& f = doc.features[i]; + features.push_back(json{ + {"index", int(i)}, {"type", feature_type_name(f.type)}, + {"name", f.name}, {"enabled", f.enabled}}); + } + + json bodies = json::array(); + for (size_t i = 0; i < doc.bodies.size(); ++i) { + json b{{"index", int(i)}, {"name", doc.bodies[i].name}, + {"has_color", doc.bodies[i].has_color}}; + // Per-body bbox/centre from the already-tessellated display meshes. + if (i < doc.display_body_meshes.size() && !doc.display_body_meshes[i].empty()) { + BoundingBoxf3 bb = doc.display_body_meshes[i].bounding_box(); + b["bbox"] = json{{"min", {bb.min.x(), bb.min.y(), bb.min.z()}}, + {"max", {bb.max.x(), bb.max.y(), bb.max.z()}}}; + Vec3d c = bb.center(); + b["center"] = {c.x(), c.y(), c.z()}; + } + bodies.push_back(std::move(b)); + } + + return json{ + {"modeling_origin", {doc.modeling_origin.x(), doc.modeling_origin.y(), doc.modeling_origin.z()}}, + {"features", std::move(features)}, + {"bodies", std::move(bodies)}, + {"error", doc.error}, + }; +} + +json action_extrude(DesignPanel* panel, const json& params) +{ + const double w = params.value("width", 20.0); + const double h = params.value("height", 20.0); + const double d = params.value("distance", 10.0); + const std::string plane_name = params.value("plane", std::string("XY")); + if (w <= 0 || h <= 0 || d <= 0) + throw std::runtime_error("width, height and distance must be > 0"); + + CadDocument& doc = panel->mcp_doc(); + SketchPlane pl = plane_name == "XZ" ? SketchPlane::XZ() + : plane_name == "YZ" ? SketchPlane::YZ() + : SketchPlane::XY(); + pl.origin = doc.modeling_origin; // land on the bed centre, like the GUI + + doc.checkpoint(); // one undo step for this action + int s = doc.add_sketch(SketchShape::Rectangle, pl, w, h, 0.0, "Sketch"); + int e = doc.add_extrude(s, d, /*symmetric*/false, BooleanMode::New, "Extrude"); + bool ok = doc.recompute(); + if (!ok) doc.undo(); // never leave a broken feature tree + panel->mcp_after_change(); // refresh tree + viewport + status + + return json{{"ok", ok}, {"sketch_index", s}, {"extrude_index", e}, + {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; +} + +// Dispatch one parsed request ON THE MAIN THREAD. Returns a JSON-RPC reply string. +std::string handle_on_main(const std::string& method, const json& params, const json& id) +{ + MainFrame* mf = wxGetApp().mainframe; + if (!mf || !mf->m_design_panel) + return rpc_error(id, -32001, "Design panel not ready"); + DesignPanel* panel = mf->m_design_panel; + try { + if (method == "describe_tools") return rpc_result(id, describe_tools()); + if (method == "describe_scene") return rpc_result(id, describe_scene(panel)); + if (method == "extrude") return rpc_result(id, action_extrude(panel, params)); + return rpc_error(id, -32601, "Unknown method: " + method); + } catch (const Standard_Failure& ex) { // OCCT errors are NOT std::exception + return rpc_error(id, -32000, std::string("OCCT: ") + (ex.GetMessageString() ? ex.GetMessageString() : "failure")); + } catch (const std::exception& ex) { + return rpc_error(id, -32000, ex.what()); + } +} + +// Marshal a request to the main thread and block (with a timeout) for the reply. +std::string dispatch_request(const std::string& line) +{ + json req; + try { req = json::parse(line); } + catch (const std::exception& ex) { return rpc_error(nullptr, -32700, std::string("parse error: ") + ex.what()); } + + json id = req.contains("id") ? req["id"] : json(nullptr); + std::string method = req.value("method", std::string()); + json params = req.contains("params") ? req["params"] : json::object(); + if (method.empty()) return rpc_error(id, -32600, "missing method"); + + auto prom = std::make_shared>(); + auto fut = prom->get_future(); + wxGetApp().CallAfter([prom, method, params, id]() { + prom->set_value(handle_on_main(method, params, id)); + }); + if (fut.wait_for(std::chrono::seconds(15)) != std::future_status::ready) + return rpc_error(id, -32000, "main-thread timeout"); + return fut.get(); +} + +// Read newline-delimited requests off one client connection until EOF. +void serve_client(int cfd) +{ + std::string buf; + char chunk[4096]; + for (;;) { + ssize_t n = ::read(cfd, chunk, sizeof(chunk)); + if (n <= 0) break; + buf.append(chunk, size_t(n)); + size_t nl; + while ((nl = buf.find('\n')) != std::string::npos) { + std::string line = buf.substr(0, nl); + buf.erase(0, nl + 1); + if (line.empty()) continue; + std::string reply = dispatch_request(line); + reply.push_back('\n'); + if (::write(cfd, reply.data(), reply.size()) < 0) return; + } + } +} + +void server_thread(std::string sock_path) +{ + ::unlink(sock_path.c_str()); + int sfd = ::socket(AF_UNIX, SOCK_STREAM, 0); + if (sfd < 0) { BOOST_LOG_TRIVIAL(error) << "MCP: socket() failed"; return; } + + sockaddr_un addr{}; + addr.sun_family = AF_UNIX; + std::strncpy(addr.sun_path, sock_path.c_str(), sizeof(addr.sun_path) - 1); + if (::bind(sfd, reinterpret_cast(&addr), sizeof(addr)) < 0) { + BOOST_LOG_TRIVIAL(error) << "MCP: bind() failed on " << sock_path; + ::close(sfd); return; + } + if (::listen(sfd, 1) < 0) { BOOST_LOG_TRIVIAL(error) << "MCP: listen() failed"; ::close(sfd); return; } + BOOST_LOG_TRIVIAL(info) << "MCP control listening on " << sock_path; + + for (;;) { + int cfd = ::accept(sfd, nullptr, nullptr); + if (cfd < 0) continue; + serve_client(cfd); + ::close(cfd); + } +} + +} // namespace + +void start_mcp_control_if_enabled() +{ + const char* env = std::getenv("SNAPORCA_MCP"); + if (!env || !*env) return; + std::string path = (std::strcmp(env, "1") == 0) ? "/tmp/snaporca-mcp.sock" : env; + static bool started = false; + if (started) return; + started = true; + std::thread(server_thread, path).detach(); +} + +}} // namespace Slic3r::GUI + +#else // _WIN32 + +namespace Slic3r { namespace GUI { +void start_mcp_control_if_enabled() {} // ponytail: no Windows transport yet +}} + +#endif diff --git a/src/slic3r/GUI/McpControl.hpp b/src/slic3r/GUI/McpControl.hpp new file mode 100644 index 0000000000..106e9888b1 --- /dev/null +++ b/src/slic3r/GUI/McpControl.hpp @@ -0,0 +1,24 @@ +#ifndef slic3r_GUI_McpControl_hpp_ +#define slic3r_GUI_McpControl_hpp_ + +// MCP control surface (slice 1): a local JSON-RPC 2.0 server, line-delimited over a +// Unix domain socket, that lets an external MCP bridge drive and perceive the Design +// tab. Off unless the env var SNAPORCA_MCP is set: +// SNAPORCA_MCP=1 -> socket at /tmp/snaporca-mcp.sock +// SNAPORCA_MCP=/path/to.sock -> socket at that path +// All CAD work is marshalled onto the wx main thread and runs through the SAME +// CadDocument kernel the GUI uses (no parallel engine). Slice-1 methods: +// describe_tools, describe_scene, extrude. +// +// ponytail: Unix-socket only (POSIX). Windows compiles this to a no-op; add a named +// pipe transport when a Windows agent actually needs it. + +namespace Slic3r { namespace GUI { + +// Start the server thread iff SNAPORCA_MCP is set. Safe to call once after the +// MainFrame + DesignPanel exist. No-op when the env var is unset or on Windows. +void start_mcp_control_if_enabled(); + +}} // namespace Slic3r::GUI + +#endif // slic3r_GUI_McpControl_hpp_