#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 #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/GeometryEngine.hpp" #include "libslic3r/TriangleMesh.hpp" #include "libslic3r/Format/OBJ.hpp" #include #include #include "libslic3r/BoundingBox.hpp" #include #include #include #include #include #include #include #include #include #include #include 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. 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", 5}, {"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", "Extrude a profile to a depth. Give an explicit closed `profile` (list of [x,y]) or default to a centred width x height rectangle. End conditions match Onshape."}, {"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{{"name", "profile"}, {"type", "array"}, {"default", json::array()}, {"description", "optional closed contour [[x,y],...] in plane mm; overrides width/height"}}, json{{"name", "boolean"}, {"type", "string"}, {"enum", json::array({"new", "union", "subtract", "intersect"})}, {"default", "new"}}, json{{"name", "end"}, {"type", "string"}, {"enum", json::array({"blind", "symmetric", "two_sided", "through_all", "up_to_face"})}, {"default", "blind"}}, json{{"name", "distance2"},{"type", "number"}, {"unit", "mm"}, {"default", 0}, {"description", "second-side depth when end=two_sided (else falls back to distance)"}}, json{{"name", "up_to_face"},{"type", "integer"}, {"default", -1}, {"description", "target face id (query_topology on the last body) when end=up_to_face"}}, json{{"name", "taper"}, {"type", "number"}, {"unit", "deg"}, {"default", 0}, {"description", "draft/taper of the side wall"}}, json{{"name", "flip"}, {"type", "boolean"}, {"default", false}}, })}}, json{{"name", "revolve"}, {"summary", "Revolve a profile about a plane axis. Give an explicit `profile` offset from the axis (or a rectangle) — angle degrees about axis 0=plane X / 1=plane Y."}, {"params", json::array({ json{{"name", "width"}, {"type", "number"}, {"unit", "mm"}, {"default", 20}, {"min", 0.01}}, json{{"name", "height"}, {"type", "number"}, {"unit", "mm"}, {"default", 10}, {"min", 0.01}}, json{{"name", "angle"}, {"type", "number"}, {"unit", "deg"}, {"default", 360}}, json{{"name", "axis"}, {"type", "integer"}, {"enum", json::array({0, 1})}, {"default", 0}}, json{{"name", "flip"}, {"type", "boolean"}, {"default", false}}, json{{"name", "plane"}, {"type", "string"}, {"enum", json::array({"XY", "XZ", "YZ"})}, {"default", "XY"}}, json{{"name", "profile"}, {"type", "array"}, {"default", json::array()}, {"description", "optional closed contour [[x,y],...] in plane mm; overrides width/height"}}, json{{"name", "boolean"}, {"type", "string"}, {"enum", json::array({"new", "union", "subtract", "intersect"})}, {"default", "new"}}, })}}, json{{"name", "fillet"}, {"summary", "Round a measured edge of a body (edge id from query_topology on that body)."}, {"params", json::array({ json{{"name", "edge"}, {"type", "integer"}}, json{{"name", "radius"}, {"type", "number"}, {"unit", "mm"}, {"default", 1}, {"min", 0.01}}, json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body. edge id is resolved against THIS body."}}, })}}, json{{"name", "chamfer"}, {"summary", "Chamfer a measured edge of a body (edge id from query_topology on that body)."}, {"params", json::array({ json{{"name", "edge"}, {"type", "integer"}}, json{{"name", "distance"}, {"type", "number"}, {"unit", "mm"}, {"default", 1}, {"min", 0.01}}, json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body. edge id is resolved against THIS body."}}, })}}, json{{"name", "hole"}, {"summary", "Drill a circular hole into the current body at (x,y) on a plane."}, {"params", json::array({ json{{"name", "diameter"}, {"type", "number"}, {"unit", "mm"}, {"default", 5}, {"min", 0.01}}, json{{"name", "depth"}, {"type", "number"}, {"unit", "mm"}, {"default", 10}, {"min", 0.01}}, json{{"name", "through"}, {"type", "boolean"}, {"default", false}}, json{{"name", "x"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}}, json{{"name", "y"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}}, json{{"name", "plane"}, {"type", "string"}, {"enum", json::array({"XY", "XZ", "YZ"})}, {"default", "XY"}}, })}}, json{{"name", "boolean"}, {"summary", "Combine two bodies: union | subtract (tool from target) | intersect."}, {"params", json::array({ json{{"name", "op"}, {"type", "string"}, {"enum", json::array({"union", "subtract", "intersect"})}, {"default", "subtract"}}, json{{"name", "target"}, {"type", "integer"}, {"default", 0}}, json{{"name", "tool"}, {"type", "integer"}, {"default", 1}}, json{{"name", "keep_tool"}, {"type", "boolean"}, {"default", false}}, json{{"name", "tolerance"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}}, })}}, json{{"name", "pattern"}, {"summary", "Replicate a body: linear (count along a plane axis at spacing) or circular (count over an angle about the plane normal)."}, {"params", json::array({ json{{"name", "circular"}, {"type", "boolean"}, {"default", false}}, json{{"name", "count"}, {"type", "integer"}, {"default", 3}, {"min", 1}}, json{{"name", "spacing"}, {"type", "number"}, {"unit", "mm"}, {"default", 10}, {"description", "linear step"}}, json{{"name", "dir"}, {"type", "integer"}, {"enum", json::array({0, 1})}, {"default", 0}, {"description", "linear axis: 0=plane X, 1=plane Y"}}, json{{"name", "angle"}, {"type", "number"}, {"unit", "deg"}, {"default", 360}, {"description", "circular total sweep"}}, json{{"name", "plane"}, {"type", "string"}, {"enum", json::array({"XY", "XZ", "YZ"})}, {"default", "XY"}}, json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body"}}, })}}, json{{"name", "shell"}, {"summary", "Hollow a body to a wall thickness (inward); optionally leave one face open."}, {"params", json::array({ json{{"name", "thickness"}, {"type", "number"}, {"unit", "mm"}, {"default", 1}, {"min", 0.01}}, json{{"name", "face"}, {"type", "integer"}, {"default", -1}, {"description", "face id to leave open (query_topology); omit for a closed hollow"}}, json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body"}}, })}}, json{{"name", "draft"}, {"summary", "Taper a body face by an angle about its base (pull direction +Z)."}, {"params", json::array({ json{{"name", "face"}, {"type", "integer"}, {"description", "face id to draft (query_topology)"}}, json{{"name", "angle"}, {"type", "number"}, {"unit", "deg"}, {"default", 5}}, json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body"}}, })}}, json{{"name", "mirror"}, {"summary", "Mirror a body about a base plane. mode=new creates a mirrored copy; mode=add fuses the mirror back into the source."}, {"params", json::array({ json{{"name", "plane"}, {"type", "string"}, {"enum", json::array({"XY", "XZ", "YZ"})}, {"default", "XZ"}}, json{{"name", "mode"}, {"type", "string"}, {"enum", json::array({"new", "add"})}, {"default", "new"}}, json{{"name", "keep_original"},{"type", "boolean"}, {"default", true}, {"description", "when mode=new, keep the source body"}}, json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body"}}, })}}, json{{"name", "query_topology"}, {"summary", "Measured faces (centroid/normal/cylinder) and edges (length/circle) of a body."}, {"params", json::array({ json{{"name", "body"}, {"type", "integer"}, {"default", 0}}, })}}, json{{"name", "measure"}, {"summary", "Distance (and angle, when both have direction) between two refs {face|edge|point} on a body."}, {"params", json::array({ json{{"name", "body"}, {"type", "integer"}, {"default", 0}}, json{{"name", "a"}, {"type", "object"}}, json{{"name", "b"}, {"type", "object"}}, })}}, json{{"name", "mass_properties"}, {"summary", "Volume / surface area / centre of mass / inertia tensor of a body."}, {"params", json::array({ json{{"name", "body"}, {"type", "integer"}, {"default", 0}}, })}}, json{{"name", "slice_body"}, {"summary", "Cross-section of a body by a base plane at an offset (sections-as-evidence); returns ordered world contours, each flagged closed/open."}, {"params", json::array({ json{{"name", "body"}, {"type", "integer"}, {"default", 0}}, json{{"name", "plane"}, {"type", "string"}, {"enum", json::array({"XY", "XZ", "YZ"})}, {"default", "XY"}}, json{{"name", "offset"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}}, })}}, json{{"name", "import_step"}, {"summary", "Import a STEP file as native B-rep bodies (the reference part to measure)."}, {"params", json::array({ json{{"name", "path"}, {"type", "string"}}, })}}, json{{"name", "import_mesh"}, {"summary", "Convert a triangle mesh (STL/OBJ) into an editable B-rep body. Reports whether the result is a real solid or an open shell, and why."}, {"params", json::array({ json{{"name", "path"}, {"type", "string"}}, json{{"name", "tolerance"}, {"type", "number"}, {"default", 0.01}}, json{{"name", "merge_angle_deg"}, {"type", "number"}, {"default", 5.0}}, })}}, json{{"name", "validate_against"}, {"summary", "Volume + bbox/centroid + surface deviation (max/mean/rms mm) of a body vs a reference {step:path|body:id} (the RE acceptance metric)."}, {"params", json::array({ json{{"name", "body"}, {"type", "integer"}, {"default", 0}}, json{{"name", "reference"}, {"type", "object"}}, })}}, })}, }; } 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}, }; } // --- Measure layer (read-only "evidence" half of the RE loop) ------------------ inline json vec3(const Vec3d& v) { return json::array({v.x(), v.y(), v.z()}); } // Shared Build helpers. SketchPlane plane_from(const json& params, const CadDocument& doc) { std::string n = params.value("plane", std::string("XY")); SketchPlane pl = n == "XZ" ? SketchPlane::XZ() : n == "YZ" ? SketchPlane::YZ() : SketchPlane::XY(); pl.origin = doc.modeling_origin; // land on the bed centre, like the GUI return pl; } BooleanMode bool_from(const std::string& s) { if (s == "union" || s == "add") return BooleanMode::Add; if (s == "subtract" || s == "cut") return BooleanMode::Cut; if (s == "intersect" || s == "common")return BooleanMode::Intersect; return BooleanMode::New; } // Optional explicit closed profile: params["profile"] = [[x,y],...] in plane mm. // This is the Measure->Build bridge — feed a measured contour straight back. bool profile_from(const json& params, SketchProfile& out) { if (!params.contains("profile")) return false; out.points.clear(); for (const auto& p : params["profile"]) out.points.emplace_back(p[0].get(), p[1].get()); out.closed = true; return out.points.size() >= 3; } // Resolve body index -> shape, throwing a clear error if out of range / null. const TopoDS_Shape& body_shape(DesignPanel* panel, const json& params) { CadDocument& doc = panel->mcp_doc(); int idx = params.value("body", 0); if (idx < 0 || idx >= int(doc.bodies.size())) throw std::runtime_error("body index out of range (have " + std::to_string(doc.bodies.size()) + ")"); if (doc.bodies[idx].shape.IsNull()) throw std::runtime_error("body has no shape"); return doc.bodies[idx].shape; } json query_topology(DesignPanel* panel, const json& params) { const TopoDS_Shape& shape = body_shape(panel, params); // Enumerate once. The _by_index accessors rescan the shape on every call (edge_by_index // rebuilds the whole indexed map), so indexing a body face-by-face is quadratic: ~15 s on a // 4.7k-face imported solid, on the UI thread. faces_of/edges_of keep the very same ids. const std::vector all_faces = GeometryEngine::faces_of(shape); const std::vector all_edges = GeometryEngine::edges_of(shape); json faces = json::array(); const int nf = int(all_faces.size()); for (int i = 0; i < nf; ++i) { const TopoDS_Face& f = all_faces[i]; if (f.IsNull()) continue; json jf{{"id", i}, {"centroid", vec3(GeometryEngine::face_centroid_world(f))}, {"normal", vec3(GeometryEngine::face_normal_world(f))}, {"kind", "planar"}}; GeometryEngine::CylinderFace cyl = GeometryEngine::cylinder_of_face(f); if (cyl.ok) { jf["kind"] = "cylindrical"; jf["radius"] = cyl.radius; jf["axis"] = vec3(cyl.axis); jf["internal"] = cyl.internal; } faces.push_back(std::move(jf)); } json edges = json::array(); const int ne = int(all_edges.size()); for (int i = 0; i < ne; ++i) { const TopoDS_Edge& e = all_edges[i]; if (e.IsNull()) continue; std::vector pts = GeometryEngine::sample_edge_world(e); if (pts.size() < 2) continue; double len = 0; for (size_t k = 1; k < pts.size(); ++k) len += (pts[k] - pts[k-1]).norm(); json je{{"id", i}, {"length", len}, {"p0", vec3(pts.front())}, {"p1", vec3(pts.back())}, {"kind", "line"}}; GeometryEngine::CylinderFace circ = GeometryEngine::circle_of_edge(e); if (circ.ok) { je["kind"] = "circle"; je["radius"] = circ.radius; je["center"] = vec3(circ.base); } edges.push_back(std::move(je)); } return json{{"body", params.value("body", 0)}, {"face_count", nf}, {"edge_count", ne}, {"faces", std::move(faces)}, {"edges", std::move(edges)}}; } // One measurement reference -> a representative point and (optionally) a direction. // ref = {"face": id} | {"edge": id} | {"point": [x,y,z]} on the given body. bool resolve_ref(const TopoDS_Shape& shape, const json& ref, Vec3d& point, Vec3d& dir, bool& has_dir) { has_dir = false; if (ref.contains("point")) { auto p = ref["point"]; point = Vec3d(p[0], p[1], p[2]); return true; } if (ref.contains("face")) { TopoDS_Face f = GeometryEngine::face_by_index(shape, ref["face"].get()); if (f.IsNull()) return false; point = GeometryEngine::face_centroid_world(f); dir = GeometryEngine::face_normal_world(f); has_dir = true; return true; } if (ref.contains("edge")) { TopoDS_Edge e = GeometryEngine::edge_by_index(shape, ref["edge"].get()); if (e.IsNull()) return false; std::vector pts = GeometryEngine::sample_edge_world(e); if (pts.empty()) return false; point = pts[pts.size() / 2]; // midpoint sample if (pts.size() >= 2) { dir = (pts.back() - pts.front()).normalized(); has_dir = true; } return true; } return false; } json measure(DesignPanel* panel, const json& params) { const TopoDS_Shape& shape = body_shape(panel, params); Vec3d pa, pb, da, db; bool hda = false, hdb = false; if (!params.contains("a") || !params.contains("b")) throw std::runtime_error("measure needs refs 'a' and 'b' ({face|edge|point})"); if (!resolve_ref(shape, params["a"], pa, da, hda) || !resolve_ref(shape, params["b"], pb, db, hdb)) throw std::runtime_error("could not resolve a measurement reference"); json r{{"distance", (pa - pb).norm()}, {"point_a", vec3(pa)}, {"point_b", vec3(pb)}}; if (hda && hdb) { double c = std::max(-1.0, std::min(1.0, da.normalized().dot(db.normalized()))); r["angle_deg"] = std::acos(c) * 180.0 / M_PI; } return r; } json mass_properties(DesignPanel* panel, const json& params) { const TopoDS_Shape& shape = body_shape(panel, params); auto mp = GeometryEngine::mass_properties(shape); if (!mp.valid) throw std::runtime_error("mass properties could not be computed (null/empty shape)"); return json{ {"volume", mp.volume}, {"surface_area", mp.surface_area}, {"center_of_mass", json::array({mp.center_of_mass.x(), mp.center_of_mass.y(), mp.center_of_mass.z()})}, {"inertia", mp.inertia}, {"valid", mp.valid}, }; } // Chain raw section segments (each a sampled-edge polyline) into ordered contours by joining // endpoints within tol. Grows the tail; when the tail is stuck, reverses the contour and grows // the other end. A contour is closed when its two ends meet. OCCT section vertices are exact, // so a small absolute tol suffices. std::vector, bool>> chain_segments(std::vector> segs, double tol) { std::vector, bool>> contours; std::vector used(segs.size(), 0); auto near = [&](const Vec3d& a, const Vec3d& b) { return (a - b).norm() <= tol; }; for (size_t i = 0; i < segs.size(); ++i) { if (used[i] || segs[i].size() < 2) continue; used[i] = 1; std::vector c = segs[i]; for (int side = 0; side < 2; ) { // grow tail; reverse once when stuck bool grew = false; for (size_t j = 0; j < segs.size(); ++j) { if (used[j] || segs[j].size() < 2) continue; if (near(c.back(), segs[j].front())) { c.insert(c.end(), segs[j].begin() + 1, segs[j].end()); used[j] = 1; grew = true; } else if (near(c.back(), segs[j].back())) { for (auto it = segs[j].rbegin() + 1; it != segs[j].rend(); ++it) c.push_back(*it); used[j] = 1; grew = true; } } if (grew) { side = 0; continue; } std::reverse(c.begin(), c.end()); ++side; // try the other end } bool closed = c.size() > 2 && near(c.front(), c.back()); contours.emplace_back(std::move(c), closed); } return contours; } // sections-as-evidence: cross-section of a body by a named base plane at an offset. json slice_body(DesignPanel* panel, const json& params) { const TopoDS_Shape& shape = body_shape(panel, params); const std::string plane_name = params.value("plane", std::string("XY")); const double offset = params.value("offset", 0.0); // Base plane normal; offset shifts the plane along it. gp_Dir n = plane_name == "XZ" ? gp_Dir(0, 1, 0) : plane_name == "YZ" ? gp_Dir(1, 0, 0) : gp_Dir(0, 0, 1); gp_Pnt o(n.X() * offset, n.Y() * offset, n.Z() * offset); BRepAlgoAPI_Section sect(shape, gp_Pln(o, n), Standard_False); sect.ComputePCurveOn1(Standard_False); sect.Approximation(Standard_True); sect.Build(); if (!sect.IsDone()) throw std::runtime_error("section failed"); std::vector> segs; for (TopExp_Explorer ex(sect.Shape(), TopAbs_EDGE); ex.More(); ex.Next()) { std::vector pts = GeometryEngine::sample_edge_world(TopoDS::Edge(ex.Current())); if (pts.size() >= 2) segs.push_back(std::move(pts)); } const int raw = int(segs.size()); auto contours = chain_segments(std::move(segs), 1e-3); json jcont = json::array(); int closed_n = 0; for (auto& pc : contours) { if (pc.second) ++closed_n; json pts = json::array(); for (const Vec3d& p : pc.first) pts.push_back(vec3(p)); jcont.push_back(json{{"closed", pc.second}, {"points", std::move(pts)}}); } return json{{"body", params.value("body", 0)}, {"plane", plane_name}, {"offset", offset}, {"segment_count", raw}, {"contour_count", int(contours.size())}, {"closed_count", closed_n}, {"contours", std::move(jcont)}}; } // --- Build: bring a reference part in (Import STEP as native B-rep bodies) ------ json import_step(DesignPanel* panel, const json& params) { if (!params.contains("path")) throw std::runtime_error("import_step needs 'path'"); const std::string path = params["path"].get(); std::string err; std::vector solids = GeometryEngine::read_step_solids(path, err); if (solids.empty()) throw std::runtime_error(err.empty() ? "no solids in STEP" : err); CadDocument& doc = panel->mcp_doc(); doc.checkpoint(); int first = int(doc.features.size()); for (const TopoDS_Shape& s : solids) { CadFeature f; f.type = CadFeatureType::Import; f.name = "STEP" + std::to_string(int(doc.features.size()) + 1); f.imported_solid = s; f.mode = BooleanMode::New; // each solid = its own coexisting body doc.features.push_back(f); } bool ok = doc.recompute(); if (!ok) doc.undo(); panel->mcp_after_change(); return json{{"ok", ok}, {"imported", int(solids.size())}, {"first_feature", first}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; } // --- Import a triangle mesh as a B-rep body (GeometryEngine::mesh_to_brep) --- // Same destination as import_step: a CadFeatureType::Import body every feature tool can edit. // The full conversion stats come back so a caller can tell an honest solid from an open shell // instead of discovering it later when a boolean silently fails. json import_mesh(DesignPanel* panel, const json& params) { if (!params.contains("path")) throw std::runtime_error("import_mesh needs 'path'"); const std::string path = params["path"].get(); const double tolerance = params.value("tolerance", 0.01); const double merge_angle_deg = params.value("merge_angle_deg", 5.0); TriangleMesh mesh; const std::string ext = boost::algorithm::to_lower_copy( boost::filesystem::path(path).extension().string()); if (ext == ".stl") { if (!mesh.ReadSTLFile(path.c_str())) throw std::runtime_error("could not read STL: " + path); } else if (ext == ".obj") { ObjInfo obj_info; std::string obj_err; if (!load_obj(path.c_str(), &mesh, obj_info, obj_err)) throw std::runtime_error("could not read OBJ: " + obj_err); } else { throw std::runtime_error("unsupported mesh format (want .stl or .obj): " + ext); } GeometryEngine::MeshBrepStats st; const TopoDS_Shape shape = GeometryEngine::mesh_to_brep(mesh.its, tolerance, merge_angle_deg, st); if (shape.IsNull()) throw std::runtime_error("mesh conversion produced no geometry"); CadDocument& doc = panel->mcp_doc(); doc.checkpoint(); const int first = int(doc.features.size()); CadFeature f; f.type = CadFeatureType::Import; f.name = "Mesh" + std::to_string(first + 1); f.imported_solid = shape; f.mode = BooleanMode::New; doc.features.push_back(f); const bool ok = doc.recompute(); if (!ok) doc.undo(); panel->mcp_after_change(); return json{{"ok", ok}, {"first_feature", first}, {"bodies", int(doc.bodies.size())}, {"input_triangles", st.input_tris}, {"kept_triangles", st.kept_tris}, {"degenerate_collapsed", st.degenerate_collapsed}, {"degenerate_sliver", st.degenerate_sliver}, {"faces_built", st.faces_built}, {"faces_failed", st.faces_failed}, {"faces_final", st.faces_final}, {"unique_edges", st.unique_edges}, {"boundary_edges", st.boundary_edges}, {"nonmanifold_edges", st.nonmanifold_edges}, {"watertight", st.watertight}, {"is_solid", st.is_solid}, {"volume", st.volume}, {"error", doc.error}}; } // --- Validate: volume + bbox deviation of a body vs a reference (the "scarto %") -- // ponytail: volume delta + bbox/centroid offset (the RE skill's actual acceptance metric). // Surface-deviation heat-map is the upgrade path (per-vertex BRepExtrema), add when needed. struct ShapeMetrics { double volume; Vec3d centroid, bmin, bmax; }; ShapeMetrics shape_metrics(const TopoDS_Shape& s) { GProp_GProps vp; BRepGProp::VolumeProperties(s, vp); gp_Pnt c = vp.CentreOfMass(); Bnd_Box bb; BRepBndLib::Add(s, bb); Standard_Real x0, y0, z0, x1, y1, z1; bb.Get(x0, y0, z0, x1, y1, z1); return {vp.Mass(), Vec3d(c.X(), c.Y(), c.Z()), Vec3d(x0, y0, z0), Vec3d(x1, y1, z1)}; } json validate_against(DesignPanel* panel, const json& params) { const TopoDS_Shape& cand = body_shape(panel, params); // candidate = the reconstruction if (!params.contains("reference")) throw std::runtime_error("validate_against needs 'reference' {step|body}"); const json& r = params["reference"]; TopoDS_Shape ref; if (r.contains("step")) { std::string err; std::vector solids = GeometryEngine::read_step_solids(r["step"].get(), err); if (solids.empty()) throw std::runtime_error(err.empty() ? "reference STEP has no solids" : err); BRep_Builder b; TopoDS_Compound comp; b.MakeCompound(comp); for (const TopoDS_Shape& s : solids) if (!s.IsNull()) b.Add(comp, s); ref = comp; } else if (r.contains("body")) { CadDocument& doc = panel->mcp_doc(); int idx = r["body"].get(); if (idx < 0 || idx >= int(doc.bodies.size()) || doc.bodies[idx].shape.IsNull()) throw std::runtime_error("reference body index out of range / null"); ref = doc.bodies[idx].shape; } else { throw std::runtime_error("reference must be {\"step\": path} or {\"body\": id}"); } ShapeMetrics a = shape_metrics(cand), b = shape_metrics(ref); double dv = b.volume > 0 ? (a.volume - b.volume) / b.volume * 100.0 : 0.0; Vec3d coff = a.centroid - b.centroid; Vec3d dmin = a.bmin - b.bmin, dmax = a.bmax - b.bmax; // Surface-level deviation (one-sided Hausdorff, candidate vertices -> reference solid): // catches local shape error that matching volume + bbox can hide. GeometryEngine::Deviation dev = GeometryEngine::surface_deviation(cand, ref); return json{ {"volume", a.volume}, {"volume_reference", b.volume}, {"volume_delta_pct", dv}, {"centroid_offset", vec3(coff)}, {"centroid_offset_mm", coff.norm()}, {"bbox", json{{"min", vec3(a.bmin)}, {"max", vec3(a.bmax)}}}, {"bbox_reference", json{{"min", vec3(b.bmin)}, {"max", vec3(b.bmax)}}}, {"bbox_delta", json{{"min", vec3(dmin)}, {"max", vec3(dmax)}}}, {"surface_deviation", json{{"max_mm", dev.max_mm}, {"mean_mm", dev.mean_mm}, {"rms_mm", dev.rms_mm}, {"samples", dev.sample_count}}}, }; } json action_extrude(DesignPanel* panel, const json& params) { const double d = params.value("distance", 10.0); if (d <= 0) throw std::runtime_error("distance must be > 0"); CadDocument& doc = panel->mcp_doc(); SketchPlane pl = plane_from(params, doc); BooleanMode mode = bool_from(params.value("boolean", std::string("new"))); SketchProfile prof; bool has_prof = profile_from(params, prof); double w = 0, h = 0; if (!has_prof) { w = params.value("width", 20.0); h = params.value("height", 20.0); if (w <= 0 || h <= 0) throw std::runtime_error("width and height must be > 0"); } doc.checkpoint(); int s = has_prof ? doc.add_sketch_profile(prof, pl, "Sketch") : doc.add_sketch(SketchShape::Rectangle, pl, w, h, 0.0, "Sketch"); int e = doc.add_extrude(s, d, /*symmetric*/false, mode, "Extrude"); // End condition (Onshape parity). apply reads extrude_end directly; the kernel `symmetric` // bool is unused, so set the field here. up_to_face id comes from query_topology on the // target (last) body. taper_deg lofts the side wall; flip negates the direction. const std::string end = params.value("end", std::string("blind")); CadFeature& fe = doc.features[e]; fe.flip = params.value("flip", false); fe.taper_deg = params.value("taper", 0.0); if (end == "symmetric") fe.extrude_end = ExtrudeEnd::Symmetric; else if (end == "two_sided") { fe.extrude_end = ExtrudeEnd::TwoSided; fe.distance2 = params.value("distance2", d); } else if (end == "through_all") fe.extrude_end = ExtrudeEnd::ThroughAll; else if (end == "up_to_face") { fe.extrude_end = ExtrudeEnd::UpToFace; fe.up_to_face = params.value("up_to_face", -1); } else fe.extrude_end = ExtrudeEnd::Blind; bool ok = doc.recompute(); if (!ok) doc.undo(); panel->mcp_after_change(); return json{{"ok", ok}, {"sketch_index", s}, {"extrude_index", e}, {"end", end}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; } json action_revolve(DesignPanel* panel, const json& params) { const double angle = params.value("angle", 360.0); const int axis = params.value("axis", 0); // 0 = plane X, 1 = plane Y const bool flip = params.value("flip", false); CadDocument& doc = panel->mcp_doc(); SketchPlane pl = plane_from(params, doc); BooleanMode mode = bool_from(params.value("boolean", std::string("new"))); SketchProfile prof; bool has_prof = profile_from(params, prof); double w = 0, h = 0; if (!has_prof) { // ponytail: rectangle centred on the axis may self-overlap; offset via `profile` w = params.value("width", 20.0); h = params.value("height", 10.0); if (w <= 0 || h <= 0) throw std::runtime_error("width and height must be > 0"); } doc.checkpoint(); int s = has_prof ? doc.add_sketch_profile(prof, pl, "Sketch") : doc.add_sketch(SketchShape::Rectangle, pl, w, h, 0.0, "Sketch"); int r = doc.add_revolve(s, angle, axis, flip, mode, "Revolve"); bool ok = doc.recompute(); if (!ok) doc.undo(); panel->mcp_after_change(); return json{{"ok", ok}, {"sketch_index", s}, {"revolve_index", r}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; } // Resolve an optional explicit body target. Default (no `body`, or <0) = last body, which is // what the kernel picks anyway. Validated BEFORE any checkpoint so a bad index throws clean. int target_body_arg(const json& params, const CadDocument& doc) { int bi = params.value("body", -1); if (bi >= int(doc.bodies.size())) throw std::runtime_error("body index out of range (have " + std::to_string(doc.bodies.size()) + ")"); return bi; // <0 -> kernel uses the last body } json action_fillet(DesignPanel* panel, const json& params) { if (!params.contains("edge")) throw std::runtime_error("fillet needs 'edge' (id from query_topology)"); const double radius = params.value("radius", 1.0); if (radius <= 0) throw std::runtime_error("radius must be > 0"); CadDocument& doc = panel->mcp_doc(); if (doc.bodies.empty()) throw std::runtime_error("no body to fillet"); int bi = target_body_arg(params, doc); doc.checkpoint(); int f = doc.add_fillet(radius, params["edge"].get(), "Fillet"); if (bi >= 0) doc.features[f].target_body = bi; // edge id resolved against THIS body's shape bool ok = doc.recompute(); if (!ok) doc.undo(); panel->mcp_after_change(); return json{{"ok", ok}, {"fillet_index", f}, {"body", bi < 0 ? int(doc.bodies.size()) - 1 : bi}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; } json action_chamfer(DesignPanel* panel, const json& params) { if (!params.contains("edge")) throw std::runtime_error("chamfer needs 'edge' (id from query_topology)"); const double dist = params.value("distance", 1.0); if (dist <= 0) throw std::runtime_error("distance must be > 0"); CadDocument& doc = panel->mcp_doc(); if (doc.bodies.empty()) throw std::runtime_error("no body to chamfer"); int bi = target_body_arg(params, doc); doc.checkpoint(); int c = doc.add_chamfer(dist, params["edge"].get(), "Chamfer"); if (bi >= 0) doc.features[c].target_body = bi; // edge id resolved against THIS body's shape bool ok = doc.recompute(); if (!ok) doc.undo(); panel->mcp_after_change(); return json{{"ok", ok}, {"chamfer_index", c}, {"body", bi < 0 ? int(doc.bodies.size()) - 1 : bi}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; } json action_hole(DesignPanel* panel, const json& params) { const double dia = params.value("diameter", 5.0); const double depth = params.value("depth", 10.0); const bool thru = params.value("through", false); const double x = params.value("x", 0.0), y = params.value("y", 0.0); if (dia <= 0) throw std::runtime_error("diameter must be > 0"); CadDocument& doc = panel->mcp_doc(); if (doc.bodies.empty()) throw std::runtime_error("no body to drill"); SketchPlane pl = plane_from(params, doc); doc.checkpoint(); int h = doc.add_hole(dia, depth, thru, x, y, pl, "Hole"); bool ok = doc.recompute(); if (!ok) doc.undo(); panel->mcp_after_change(); return json{{"ok", ok}, {"hole_index", h}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; } json action_boolean(DesignPanel* panel, const json& params) { BooleanMode m = bool_from(params.value("op", std::string("subtract"))); if (m == BooleanMode::New) throw std::runtime_error("op must be union | subtract | intersect"); const int target = params.value("target", 0); const int tool = params.value("tool", 1); const bool keep = params.value("keep_tool", false); const double tol = params.value("tolerance", 0.0); CadDocument& doc = panel->mcp_doc(); int n = int(doc.bodies.size()); if (target < 0 || target >= n || tool < 0 || tool >= n) throw std::runtime_error("target/tool body index out of range (have " + std::to_string(n) + ")"); if (target == tool) throw std::runtime_error("target and tool must differ"); doc.checkpoint(); int b = doc.add_boolean(m, target, tool, keep, tol, -1, -1, "Boolean"); bool ok = doc.recompute(); if (!ok) doc.undo(); panel->mcp_after_change(); return json{{"ok", ok}, {"boolean_index", b}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; } json action_pattern(DesignPanel* panel, const json& params) { const bool circular = params.value("circular", false); const int count = params.value("count", 3); const double spacing = params.value("spacing", 10.0); // linear step (mm) const int dir = params.value("dir", 0); // 0 = plane X, 1 = plane Y const double angle = params.value("angle", 360.0); // circular total sweep (deg) if (count < 1) throw std::runtime_error("count must be >= 1"); CadDocument& doc = panel->mcp_doc(); if (doc.bodies.empty()) throw std::runtime_error("no body to pattern"); int bi = target_body_arg(params, doc); doc.checkpoint(); int p = doc.add_pattern(circular, count, spacing, dir, angle, bi, "Pattern"); doc.features[p].plane = plane_from(params, doc); // axis (circular) / step dirs (linear) bool ok = doc.recompute(); if (!ok) doc.undo(); panel->mcp_after_change(); return json{{"ok", ok}, {"pattern_index", p}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; } json action_shell(DesignPanel* panel, const json& params) { const double thickness = params.value("thickness", 1.0); if (thickness <= 0) throw std::runtime_error("thickness must be > 0"); const int face = params.value("face", -1); // face id to leave open (-1 = closed hollow) CadDocument& doc = panel->mcp_doc(); if (doc.bodies.empty()) throw std::runtime_error("no body to shell"); int bi = target_body_arg(params, doc); doc.checkpoint(); int s = doc.add_shell(thickness, face, bi, "Shell"); bool ok = doc.recompute(); if (!ok) doc.undo(); panel->mcp_after_change(); return json{{"ok", ok}, {"shell_index", s}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; } json action_draft(DesignPanel* panel, const json& params) { if (!params.contains("face")) throw std::runtime_error("draft needs 'face' (id from query_topology)"); const double angle = params.value("angle", 5.0); CadDocument& doc = panel->mcp_doc(); if (doc.bodies.empty()) throw std::runtime_error("no body to draft"); int bi = target_body_arg(params, doc); doc.checkpoint(); int d = doc.add_draft(angle, params["face"].get(), bi, "Draft"); bool ok = doc.recompute(); if (!ok) doc.undo(); panel->mcp_after_change(); return json{{"ok", ok}, {"draft_index", d}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}}; } json action_mirror(DesignPanel* panel, const json& params) { std::string m_str = params.value("mode", std::string("new")); BooleanMode m = (m_str == "add") ? BooleanMode::Add : BooleanMode::New; CadDocument& doc = panel->mcp_doc(); if (doc.bodies.empty()) throw std::runtime_error("no body to mirror"); int bi = target_body_arg(params, doc); bool keep = params.value("keep_original", true); doc.checkpoint(); int idx = doc.add_mirror(plane_from(params, doc), bi, m, "Mirror"); doc.features[idx].mirror_keep_original = keep; bool ok = doc.recompute(); if (!ok) doc.undo(); panel->mcp_after_change(); return json{{"ok", ok}, {"mirror_index", idx}, {"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 == "query_topology") return rpc_result(id, query_topology(panel, params)); if (method == "measure") return rpc_result(id, measure(panel, params)); if (method == "mass_properties") return rpc_result(id, mass_properties(panel, params)); if (method == "slice_body") return rpc_result(id, slice_body(panel, params)); if (method == "import_step") return rpc_result(id, import_step(panel, params)); if (method == "import_mesh") return rpc_result(id, import_mesh(panel, params)); if (method == "validate_against") return rpc_result(id, validate_against(panel, params)); if (method == "extrude") return rpc_result(id, action_extrude(panel, params)); if (method == "revolve") return rpc_result(id, action_revolve(panel, params)); if (method == "fillet") return rpc_result(id, action_fillet(panel, params)); if (method == "chamfer") return rpc_result(id, action_chamfer(panel, params)); if (method == "hole") return rpc_result(id, action_hole(panel, params)); if (method == "boolean") return rpc_result(id, action_boolean(panel, params)); if (method == "pattern") return rpc_result(id, action_pattern(panel, params)); if (method == "shell") return rpc_result(id, action_shell(panel, params)); if (method == "draft") return rpc_result(id, action_draft(panel, params)); if (method == "mirror") return rpc_result(id, action_mirror(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