Files
OrcaSlicer/src/slic3r/GUI/McpControl.cpp
T
Tommaso BianchiandClaude Opus 4.8 017bb08a1d CAD: helix / spiral curve, consumable as a Sweep path
Adds CadFeatureType::Helix — the missing input for Sweep. Sweep already
existed but could only follow a sketch, so springs, coils, augers and
non-standard-pitch threads were unreachable. Helix + the existing Sweep now
gives all of them with no further work.

Built the OCCT way: a 2D line on a Geom_CylindricalSurface (Geom_ConicalSurface
when helix_taper_deg != 0) turned into an edge and lifted to 3D with
BRepLib::BuildCurves3d — a true analytic helix, not a sampled polyline, so a
swept spring is smooth rather than faceted. The axis is the plane normal
through the plane origin, matching how Revolve and Plane already work.

Sweep's path resolution is widened to accept either a Sketch (unchanged
behaviour) or a Helix, and rejects anything else with a clear error. Helix
itself is skipped in route_feature and recompute — like the datum features, it
produces no body and exists to be consumed.

Invalid input is refused rather than approximated: non-positive radius or
pitch, negative height, a turn count above 10000 (which would hang OCCT), and
a taper that would drive the radius negative before reaching the top all fail
with a specific error.

Serialization: helix_radius/pitch/height/left_handed/taper_deg appended at the
very end of both save and load, identical order, after the coordsys block.
SNAPORCA_CAD_RECIPE_VERSION stays 2; Helix is appended to the end of
CadFeatureType. Golden fixture regenerated with distinctive literals and
field-value assertions; all pre-existing assertions pass unchanged.

Tests assert analytic values. The one that actually proves it is a helix and
not a circle or a spiral: arc length of r=5 pitch=2 height=10 measured with
BRepGProp::LinearProperties against 5*sqrt((2*pi*5)^2 + 2^2), WithinRel 1e-3.
Plus bounding box (2r in X and Y, height in Z), the conical top radius, the
left-handed winding compared at equal parameter, and the integration test:
a circle r=1.5 swept along a 5-turn helix gives one valid solid of ~1115 mm^3
(WithinRel 0.1 — pipe sweeping is not exact).

MCP: `helix` method registered in describe_tools().

Ported from snaporca 6cdc6b5459. Two fork-specific adjustments: the two new
error-message assertions use Catch2 v3's ContainsSubstring (v2's Contains does
not exist here), and the golden fixture is copied rather than regenerated
because this fork cannot be compiled locally — make_golden_doc_v1() is
byte-identical across both forks, so the blob is provably the same.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BVzKmX6Y1aEteit1HTXG4Q
2026-07-24 16:39:36 +02:00

1047 lines
56 KiB
C++

#include "McpControl.hpp"
#ifndef _WIN32 // POSIX Unix-domain-socket transport only (slice 1)
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
#include <cstdlib>
#include <cstring>
#include <string>
#include <vector>
#include <thread>
#include <future>
#include <chrono>
#include <memory>
#include <cmath>
#include <algorithm>
#include <utility>
#include <nlohmann/json.hpp>
#include <boost/log/trivial.hpp>
#include <Standard_Failure.hxx> // 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 <boost/algorithm/string/case_conv.hpp>
#include <boost/filesystem/path.hpp>
#include "libslic3r/BoundingBox.hpp"
#include <gp_Pln.hxx>
#include <BRepAlgoAPI_Section.hxx>
#include <TopExp_Explorer.hxx>
#include <TopoDS.hxx>
#include <TopoDS_Edge.hxx>
#include <TopoDS_Compound.hxx>
#include <BRep_Builder.hxx>
#include <GProp_GProps.hxx>
#include <BRepGProp.hxx>
#include <Bnd_Box.hxx>
#include <BRepBndLib.hxx>
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";
case CadFeatureType::Axis: return "Axis";
case CadFeatureType::CoordSys: return "CoordSys";
}
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", "axis"}, {"summary", "Create a datum axis (reference line): two points, face normal, cylinder centreline, plane intersection, or along edge."},
{"params", json::array({
json{{"name", "type"}, {"type", "string"}, {"enum", json::array({"two_points", "face_normal", "cylinder", "plane_intersection", "along_edge"})}, {"default", "two_points"}},
json{{"name", "p1"}, {"type", "array"}, {"default", json::array({0,0,0})}, {"description", "first point [x,y,z] for two_points"}},
json{{"name", "p2"}, {"type", "array"}, {"default", json::array({0,0,10})}, {"description", "second point [x,y,z] for two_points"}},
json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "body for face/edge refs"}},
json{{"name", "face"}, {"type", "integer"}, {"default", -1}, {"description", "face id (query_topology) for face_normal/cylinder"}},
json{{"name", "edge"}, {"type", "integer"}, {"default", -1}, {"description", "edge id (query_topology) for along_edge"}},
json{{"name", "plane_a"}, {"type", "integer"}, {"default", -1}, {"description", "first datum plane feature index for plane_intersection"}},
json{{"name", "plane_b"}, {"type", "integer"}, {"default", -1}, {"description", "second datum plane feature index for plane_intersection"}},
})}},
json{{"name", "coordsys"}, {"summary", "Create a datum coordinate system (origin + orthonormal axes). PointWorld aligns to world; FaceAndDirection uses a face for Z and an edge/hint for X."},
{"params", json::array({
json{{"name", "type"}, {"type", "string"}, {"enum", json::array({"point_world", "face_and_direction"})}, {"default", "point_world"}},
json{{"name", "point"}, {"type", "array"}, {"default", json::array({0,0,0})}, {"description", "origin [x,y,z] for point_world"}},
json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "body for face/edge refs"}},
json{{"name", "face"}, {"type", "integer"}, {"default", -1}, {"description", "face id (query_topology) for Z axis"}},
json{{"name", "edge"}, {"type", "integer"}, {"default", -1}, {"description", "edge id (query_topology) for X axis hint"}},
json{{"name", "x_hint"}, {"type", "array"}, {"default", json::array({1,0,0})}, {"description", "fallback X direction hint if no edge given"}},
})}},
json{{"name", "helix"}, {"summary", "Create a helical curve (consumed by sweep as a path to build springs/coils/augers). pitch = axial rise per turn. left_handed flips the winding. taper_deg != 0 gives a conical helix."},
{"params", json::array({
json{{"name", "radius"}, {"type", "number"}, {"unit", "mm"}, {"default", 10}, {"min", 0.01}},
json{{"name", "pitch"}, {"type", "number"}, {"unit", "mm"}, {"default", 5}, {"min", 0.01}},
json{{"name", "height"}, {"type", "number"}, {"unit", "mm"}, {"default", 20}, {"min", 0.01}},
json{{"name", "left_handed"}, {"type", "boolean"}, {"default", false}},
json{{"name", "taper_deg"}, {"type", "number"}, {"unit", "deg"}, {"default", 0}},
json{{"name", "plane"}, {"type", "string"}, {"enum", json::array({"XY", "XZ", "YZ"})}, {"default", "XY"}},
})}},
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<double>(), p[1].get<double>());
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<TopoDS_Face> all_faces = GeometryEngine::faces_of(shape);
const std::vector<TopoDS_Edge> 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<Vec3d> 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<int>());
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<int>());
if (e.IsNull()) return false;
std::vector<Vec3d> 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<std::pair<std::vector<Vec3d>, bool>>
chain_segments(std::vector<std::vector<Vec3d>> segs, double tol)
{
std::vector<std::pair<std::vector<Vec3d>, bool>> contours;
std::vector<char> 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<Vec3d> 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<std::vector<Vec3d>> segs;
for (TopExp_Explorer ex(sect.Shape(), TopAbs_EDGE); ex.More(); ex.Next()) {
std::vector<Vec3d> 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>();
std::string err;
std::vector<TopoDS_Shape> 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<std::string>();
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<TopoDS_Shape> solids = GeometryEngine::read_step_solids(r["step"].get<std::string>(), 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<int>();
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<int>(), "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<int>(), "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<int>(), 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}};
}
json action_axis(DesignPanel* panel, const json& params)
{
CadDocument& doc = panel->mcp_doc();
std::string t = params.value("type", std::string("two_points"));
AxisType at = AxisType::TwoPoints;
if (t == "face_normal") at = AxisType::FaceNormal;
else if (t == "cylinder") at = AxisType::CylinderCenterline;
else if (t == "plane_intersection") at = AxisType::PlaneIntersection;
else if (t == "along_edge") at = AxisType::AlongEdge;
doc.checkpoint();
int idx = doc.add_axis(at, "Axis");
CadFeature& f = doc.features[idx];
if (params.contains("p1") && params["p1"].is_array() && params["p1"].size() >= 3)
f.axis_p1 = Vec3d(params["p1"][0].get<double>(), params["p1"][1].get<double>(), params["p1"][2].get<double>());
if (params.contains("p2") && params["p2"].is_array() && params["p2"].size() >= 3)
f.axis_p2 = Vec3d(params["p2"][0].get<double>(), params["p2"][1].get<double>(), params["p2"][2].get<double>());
f.axis_body = params.value("body", -1);
f.axis_face = params.value("face", -1);
f.axis_edge = params.value("edge", -1);
f.axis_plane_a = params.value("plane_a", -1);
f.axis_plane_b = params.value("plane_b", -1);
// Datum features don't produce a body; check for an error returned by resolve.
bool recompute_ok = doc.recompute();
auto axes = doc.resolve_datum_axes();
std::string err = doc.error;
if (recompute_ok && err.empty() && !axes.empty() && !axes.back().error.empty())
err = axes.back().error;
panel->mcp_after_change();
return json{{"ok", true}, {"axis_index", idx}, {"error", err}};
}
json action_coordsys(DesignPanel* panel, const json& params)
{
CadDocument& doc = panel->mcp_doc();
std::string t = params.value("type", std::string("point_world"));
CoordSysType ct = CoordSysType::PointWorld;
if (t == "face_and_direction") ct = CoordSysType::FaceAndDirection;
Vec3d pt(0, 0, 0);
if (params.contains("point") && params["point"].is_array() && params["point"].size() >= 3)
pt = Vec3d(params["point"][0].get<double>(), params["point"][1].get<double>(), params["point"][2].get<double>());
doc.checkpoint();
int idx = doc.add_coordsys(ct, pt, "CoordSys");
CadFeature& f = doc.features[idx];
f.coordsys_body = params.value("body", -1);
f.coordsys_face = params.value("face", -1);
f.coordsys_edge = params.value("edge", -1);
if (params.contains("x_hint") && params["x_hint"].is_array() && params["x_hint"].size() >= 3)
f.coordsys_x_hint = Vec3d(params["x_hint"][0].get<double>(), params["x_hint"][1].get<double>(), params["x_hint"][2].get<double>());
bool recompute_ok = doc.recompute();
auto css = doc.resolve_datum_coordsys();
std::string err = doc.error;
if (recompute_ok && err.empty() && !css.empty() && !css.back().error.empty())
err = css.back().error;
panel->mcp_after_change();
return json{{"ok", true}, {"coordsys_index", idx}, {"error", err}};
}
json action_helix(DesignPanel* panel, const json& params)
{
const double radius = params.value("radius", 10.0);
const double pitch = params.value("pitch", 5.0);
const double height = params.value("height", 20.0);
const bool left_handed = params.value("left_handed", false);
const double taper = params.value("taper_deg", 0.0);
CadDocument& doc = panel->mcp_doc();
doc.checkpoint();
int idx = doc.add_helix(plane_from(params, doc), radius, pitch, height, left_handed, taper, "Helix");
panel->mcp_after_change();
return json{{"ok", true}, {"helix_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));
if (method == "axis") return rpc_result(id, action_axis(panel, params));
if (method == "coordsys") return rpc_result(id, action_coordsys(panel, params));
if (method == "helix") return rpc_result(id, action_helix(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<std::promise<std::string>>();
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<sockaddr*>(&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