Files
OrcaSlicer/src/slic3r/GUI/CAD/McpControl.cpp
T

2305 lines
128 KiB
C++

#include "slic3r/GUI/CAD/McpControl.hpp"
#include "libslic3r/Point.hpp"
#include <stdexcept>
#include <math.h>
#include <TopAbs_ShapeEnum.hxx>
#include <Standard_TypeDef.hxx>
#include <exception>
#include <cstdint>
#ifndef _WIN32 // POSIX Unix-domain-socket transport only (slice 1)
#include <sys/socket.h>
#include <sys/un.h>
#include <sys/stat.h> // umask/chmod: the socket's file mode IS its access control
#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 <atomic>
#include <set>
#include <nlohmann/json.hpp>
#include <boost/log/trivial.hpp>
#include <Standard_Failure.hxx> // OCCT base error
#include "slic3r/GUI/GUI_App.hpp"
#include "slic3r/GUI/MainFrame.hpp"
#include "slic3r/GUI/CAD/DesignPanel.hpp"
#include "slic3r/GUI/CAD/DesignCanvas.hpp"
#include "slic3r/GUI/CAD/DesignSketchTool.hpp"
#include "slic3r/GUI/CAD/DesignOffer.hpp" // offer table — served whole by list_verbs, fired by run_verb
#include "libslic3r/CAD/CadDocument.hpp"
#include "libslic3r/CAD/SketchEngine.hpp"
#include "libslic3r/CAD/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::Mirror: return "Mirror";
case CadFeatureType::Axis: return "Axis";
case CadFeatureType::CoordSys: return "CoordSys";
case CadFeatureType::Helix: return "Helix";
case CadFeatureType::Transform: return "Transform";
case CadFeatureType::Thicken: return "Thicken";
case CadFeatureType::Project: return "Project";
case CadFeatureType::DeleteFace: return "DeleteFace";
case CadFeatureType::Rib: return "Rib";
case CadFeatureType::SurfaceExtrude: return "SurfaceExtrude";
case CadFeatureType::SurfaceRevolve: return "SurfaceRevolve";
case CadFeatureType::ThickenSurface: return "ThickenSurface";
case CadFeatureType::SurfaceOffset: return "SurfaceOffset";
case CadFeatureType::SurfaceLoft: return "SurfaceLoft";
case CadFeatureType::SurfaceFill: return "SurfaceFill";
case CadFeatureType::Mate: return "Mate";
}
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", "Orca CAD"},
{"protocol", "jsonrpc-2.0"},
{"slice", 5},
// Read this before using any face or edge id.
{"id_lifetime",
"Global face and edge ids are indices into the CURRENT topology and expire the moment "
"a feature rebuilds the model. Reading the scene once and then issuing several "
"operations addresses the wrong edge on every call after the first, and does NOT "
"error, because a stale id still names a real edge. Either re-read query_topology "
"before each id-taking call, or pass the 'generation' you were given back with the "
"call and have it refused if the model has moved on."},
{"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 measured edges of a body (edge ids from query_topology on that body)."},
{"params", json::array({
json{{"name", "edge"}, {"type", "integer|array"}, {"description", "one edge id, or an array of ids rounded together in one feature"}},
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 measured edges of a body (edge ids from query_topology on that body)."},
{"params", json::array({
json{{"name", "edge"}, {"type", "integer|array"}, {"description", "one edge id, or an array of ids chamfered together in one feature"}},
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}, {"description", "in the sketch plane's frame (origin = describe_scene.modeling_origin), NOT world"}},
json{{"name", "y"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}, {"description", "in the sketch plane's frame (origin = describe_scene.modeling_origin), NOT world"}},
json{{"name", "plane"}, {"type", "string"}, {"enum", json::array({"XY", "XZ", "YZ"})}, {"default", "XY"}},
})}},
json{{"name", "hole_styled"}, {"summary", "Drill a hole with optional counterbore (style=1) or countersink (style=2) 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", true}},
json{{"name", "x"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}, {"description", "in the sketch plane's frame (origin = describe_scene.modeling_origin), NOT world"}},
json{{"name", "y"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}, {"description", "in the sketch plane's frame (origin = describe_scene.modeling_origin), NOT world"}},
json{{"name", "plane"}, {"type", "string"}, {"enum", json::array({"XY", "XZ", "YZ"})}, {"default", "XY"}},
json{{"name", "style"}, {"type", "integer"}, {"default", 0}, {"description", "0=simple, 1=counterbore, 2=countersink"}},
json{{"name", "cbore_diameter"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}},
json{{"name", "cbore_depth"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}},
json{{"name", "csink_diameter"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}},
json{{"name", "csink_angle"}, {"type", "number"}, {"unit", "deg"}, {"default", 90}},
json{{"name", "standard"}, {"type", "string"}, {"default", ""}, {"description", "provenance designation, e.g. M6"}},
})}},
json{{"name", "hole_standard"}, {"summary", "Drill a standard clearance hole (ISO 273 / ANSI) at (x,y) on a plane. style: 0=simple, 1=counterbore, 2=countersink."},
{"params", json::array({
json{{"name", "designation"}, {"type", "string"}, {"description", "e.g. M6, 1/4-20"}},
json{{"name", "style"}, {"type", "integer"}, {"default", 0}},
json{{"name", "through"}, {"type", "boolean"}, {"default", true}},
json{{"name", "depth"}, {"type", "number"}, {"unit", "mm"}, {"default", 10}, {"min", 0.01}},
json{{"name", "x"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}, {"description", "in the sketch plane's frame (origin = describe_scene.modeling_origin), NOT world"}},
json{{"name", "y"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}, {"description", "in the sketch plane's frame (origin = describe_scene.modeling_origin), NOT world"}},
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", "pattern_on_curve"}, {"summary", "Replicate a body along a sketch curve: `count` copies placed at equal-parameter points on the entity, each translated by (P_i - P_0)."},
{"params", json::array({
json{{"name", "count"}, {"type", "integer"}, {"default", 3}, {"min", 1}},
json{{"name", "sketch"}, {"type", "integer"}, {"description", "feature index of the sketch holding the guide curve"}},
json{{"name", "entity"}, {"type", "integer"}, {"description", "entity index of the guide curve within that sketch"}},
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", "transform"}, {"summary", "Move and/or rotate a body (B-rep transform). copy=true keeps the source and appends the transformed copy as a new body."},
{"params", json::array({
json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body"}},
json{{"name", "dx"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}},
json{{"name", "dy"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}},
json{{"name", "dz"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}},
json{{"name", "axis_x"}, {"type", "number"}, {"default", 0}},
json{{"name", "axis_y"}, {"type", "number"}, {"default", 0}},
json{{"name", "axis_z"}, {"type", "number"}, {"default", 1}},
json{{"name", "pivot_x"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}},
json{{"name", "pivot_y"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}},
json{{"name", "pivot_z"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}},
json{{"name", "angle"}, {"type", "number"}, {"unit", "deg"}, {"default", 0}},
json{{"name", "copy"}, {"type", "boolean"}, {"default", false}},
})}},
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", "thicken"}, {"summary", "Offset a face of a body by a wall thickness, producing a new thin solid body."},
{"params", json::array({
json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body"}},
json{{"name", "face"}, {"type", "integer"}, {"description", "face id to thicken (query_topology)"}},
json{{"name", "thickness"}, {"type", "number"}, {"unit", "mm"}, {"default", 2}, {"min", 0.01}},
json{{"name", "flip"}, {"type", "boolean"}, {"default", false}},
})}},
json{{"name", "split"}, {"summary", "Split a body along the plane of a picked face."},
{"params", json::array({
json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body"}},
json{{"name", "face_body"}, {"type", "integer"}, {"default", -1}, {"description", "body that owns the face; -1 = target"}},
json{{"name", "face"}, {"type", "integer"}, {"description", "face id to split along (query_topology)"}},
json{{"name", "keep_upper"},{"type", "boolean"}, {"default", true}},
json{{"name", "keep_lower"},{"type", "boolean"}, {"default", true}},
})}},
json{{"name", "project"}, {"summary", "Project edges of a solid onto a sketch plane, producing a new sketch feature."},
{"params", json::array({
json{{"name", "source_body"}, {"type", "integer"}, {"default", -1}, {"description", "body owning the edges; -1 = last body"}},
json{{"name", "face"}, {"type", "integer"}, {"default", -1}, {"description", "global face id on the source body; when set, all its edges are projected"}},
json{{"name", "edges"}, {"type", "array"}, {"default", json::array()}, {"description", "global edge ids to project; empty => project the face"}},
json{{"name", "plane"}, {"type", "string"}, {"default", "XY"}, {"description", "target sketch plane (XY/XZ/YZ)"}},
})}},
json{{"name", "delete_face"}, {"summary", "Remove faces from a solid, healing the gap via OCCT defeaturing."},
{"params", json::array({
json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body"}},
json{{"name", "faces"}, {"type", "array"}, {"description", "global face ids to delete"}},
})}},
json{{"name", "bridge"}, {"summary", "Build a cubic-Bezier G1 bridge (BSpline) between two sketch-entity endpoints within a sketch feature."},
{"params", json::array({
json{{"name", "sketch"}, {"type", "integer"}, {"description", "sketch feature index"}},
json{{"name", "ent_a"}, {"type", "integer"}, {"description", "first entity index within the sketch"}},
json{{"name", "end_a"}, {"type", "integer"}, {"enum", json::array({0, 1})}, {"default", 1}, {"description", "0 = start/p0 side, 1 = end/p1 side"}},
json{{"name", "ent_b"}, {"type", "integer"}, {"description", "second entity index within the sketch"}},
json{{"name", "end_b"}, {"type", "integer"}, {"enum", json::array({0, 1})}, {"default", 0}, {"description", "0 = start/p0 side, 1 = end/p1 side"}},
})}},
json{{"name", "rib"}, {"summary", "Grow a thin rib wall (stiffener) from an open Line sketch entity, fused to a body."},
{"params", json::array({
json{{"name", "sketch"}, {"type", "integer"}, {"description", "sketch feature index holding the open line"}},
json{{"name", "entity"}, {"type", "integer"}, {"description", "entity index of the open Line within the sketch"}},
json{{"name", "thickness"}, {"type", "number"}, {"unit", "mm"}, {"default", 2}, {"min", 0.01}},
json{{"name", "depth"}, {"type", "number"}, {"unit", "mm"}, {"default", 10}, {"min", 0.01}},
json{{"name", "body"}, {"type", "integer"}, {"default", -1}, {"description", "target body; omit for the last body"}},
})}},
json{{"name", "surface_extrude"}, {"summary", "Extrude a sketch wire with no end caps -> an open sheet body."},
{"params", json::array({
json{{"name", "sketch"}, {"type", "integer"}, {"description", "sketch feature index"}},
json{{"name", "distance"},{"type", "number"}, {"unit", "mm"}, {"default", 10}, {"min", 0.01}},
})}},
json{{"name", "surface_revolve"}, {"summary", "Revolve a sketch wire with no caps -> an open sheet body."},
{"params", json::array({
json{{"name", "sketch"}, {"type", "integer"}, {"description", "sketch feature index"}},
json{{"name", "angle"}, {"type", "number"}, {"unit", "deg"}, {"default", 360}},
json{{"name", "axis"}, {"type", "integer"}, {"enum", json::array({0, 1})}, {"default", 0}},
})}},
json{{"name", "mate"}, {"summary", "Mate two bodies: transform the moving body (cs_b) so its connector lands on the fixed one (cs_a). kind: 0=Fastened, 1=Planar, 2=Revolute, 3=Slider, 4=Cylindrical."},
{"params", json::array({
json{{"name", "kind"}, {"type", "integer"}, {"default", 0}, {"description", "0=Fastened (rigid), 1=Planar (normal only), 2=Revolute (free rotation about axis), 3=Slider (free translation along axis), 4=Cylindrical (free rotation+translation)"}},
json{{"name", "cs_a"}, {"type", "integer"}, {"description", "feature index of the fixed CoordSys (mate connector A)"}},
json{{"name", "cs_b"}, {"type", "integer"}, {"description", "feature index of the CoordSys on the body that moves"}},
json{{"name", "offset"}, {"type", "number"}, {"unit", "mm"}, {"default", 0}},
json{{"name", "angle"}, {"type", "number"}, {"unit", "deg"}, {"default", 0}},
json{{"name", "flip"}, {"type", "boolean"}, {"default", false}},
})}},
json{{"name", "check_interference"}, {"summary", "Solid bodies that overlap, as {body_a, body_b, volume}. Read-only; bodies that merely touch enclose no volume and are not reported."},
{"params", json::array({
json{{"name", "min_volume"}, {"type", "number"}, {"unit", "mm^3"}, {"default", 1e-6}, {"description", "overlap volume above which a pair counts as interfering"}},
})}},
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) {
// The user's name when the body has one, the derived maker name otherwise — the same
// rule the Bodies row shows, so a driver and a person read the same thing.
json b{{"index", int(i)},
{"name", doc.bodies[i].has_user_name ? doc.bodies[i].user_name : doc.bodies[i].name},
{"user_name", doc.bodies[i].has_user_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},
// Pass this back as "generation" on any call that takes a face or edge id and the call
// is refused if the topology has moved on. See the note on the dispatcher.
{"generation", doc.topo_generation},
};
}
// --- 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)},
// The ids above are indices into this exact topology and expire with it. Echo
// this back as "generation" on the calls that consume them.
{"generation", panel->mcp_doc().topo_generation}};
}
// 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)");
// A sheet body has no volume and no inertia. Report the area and say so, rather than
// returning numbers a caller would reasonably treat as a material check.
if (!mp.is_solid)
return json{
{"surface_area", mp.surface_area},
{"volume", 0.0},
{"is_solid", false},
{"valid", mp.valid},
{"note", "sheet body (open shell): it encloses no material, so volume and inertia "
"are not defined; surface_area is exact"},
};
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},
{"is_solid", true},
{"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 meets = [&](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 (meets(c.back(), segs[j].front())) {
c.insert(c.end(), segs[j].begin() + 1, segs[j].end()); used[j] = 1; grew = true;
} else if (meets(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 && meets(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), false);
sect.ComputePCurveOn1(false);
sect.Approximation(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) { const std::string why = doc.error; doc.undo(); doc.error = why; }
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) { const std::string why = doc.error; doc.undo(); doc.error = why; }
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);
double 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) { const std::string why = doc.error; doc.undo(); doc.error = why; }
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) { const std::string why = doc.error; doc.undo(); doc.error = why; }
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
}
// `edge` is one id or an array of ids; an array becomes ONE feature, every id resolved
// against the same body (a chain of single-edge features would see the ids drift).
std::vector<int> edge_ids_arg(const json& params, const char* verb)
{
if (!params.contains("edge"))
throw std::runtime_error(std::string(verb) + " needs 'edge' (id or array of ids from query_topology)");
const json& e = params["edge"];
std::vector<int> ids;
if (e.is_array()) for (const json& v : e) ids.push_back(v.get<int>());
else ids.push_back(e.get<int>());
if (ids.empty()) throw std::runtime_error(std::string(verb) + ": 'edge' is an empty array");
return ids;
}
json action_fillet(DesignPanel* panel, const json& params)
{
const std::vector<int> edges = edge_ids_arg(params, "fillet");
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, edges, "Fillet");
if (bi >= 0) doc.features[f].target_body = bi; // edge id resolved against THIS body's shape
bool ok = doc.recompute();
if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; }
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)
{
const std::vector<int> edges = edge_ids_arg(params, "chamfer");
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, edges, "Chamfer");
if (bi >= 0) doc.features[c].target_body = bi; // edge id resolved against THIS body's shape
bool ok = doc.recompute();
if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; }
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) { const std::string why = doc.error; doc.undo(); doc.error = why; }
panel->mcp_after_change();
return json{{"ok", ok}, {"hole_index", h}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}};
}
json action_hole_styled(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", true);
const double x = params.value("x", 0.0), y = params.value("y", 0.0);
const int style = params.value("style", 0);
const double cbore_diameter = params.value("cbore_diameter", 0.0);
const double cbore_depth = params.value("cbore_depth", 0.0);
const double csink_diameter = params.value("csink_diameter", 0.0);
const double csink_angle = params.value("csink_angle", 90.0);
const std::string standard = params.value("standard", std::string(""));
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_styled(dia, depth, thru, x, y, pl, style,
cbore_diameter, cbore_depth,
csink_diameter, csink_angle, standard, "Hole");
bool ok = doc.recompute();
if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; }
panel->mcp_after_change();
return json{{"ok", ok}, {"hole_index", h}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}};
}
json action_hole_standard(DesignPanel* panel, const json& params)
{
const std::string desig = params.value("designation", std::string(""));
if (desig.empty()) throw std::runtime_error("designation is required");
const int style = params.value("style", 0);
const bool thru = params.value("through", true);
const double depth = params.value("depth", 10.0);
const double x = params.value("x", 0.0), y = params.value("y", 0.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();
try {
int h = doc.add_hole_standard(desig, style, thru, depth, x, y, pl, "Hole");
bool ok = doc.recompute();
if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; }
panel->mcp_after_change();
return json{{"ok", ok}, {"hole_index", h}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}};
} catch (const std::exception& ex) {
doc.undo();
panel->mcp_after_change();
return json{{"ok", false}, {"error", ex.what()}};
}
}
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) { const std::string why = doc.error; doc.undo(); doc.error = why; }
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) { const std::string why = doc.error; doc.undo(); doc.error = why; }
panel->mcp_after_change();
return json{{"ok", ok}, {"pattern_index", p}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}};
}
json action_pattern_on_curve(DesignPanel* panel, const json& params)
{
const int count = params.value("count", 3);
if (count < 1) throw std::runtime_error("count must be >= 1");
if (!params.contains("sketch")) throw std::runtime_error("pattern_on_curve needs 'sketch' (feature index)");
if (!params.contains("entity")) throw std::runtime_error("pattern_on_curve needs 'entity' (entity index)");
const int sketch = params["sketch"].get<int>();
const int entity = params["entity"].get<int>();
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_on_curve(count, sketch, entity, bi, "PatternOnCurve");
bool ok = doc.recompute();
if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; }
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) { const std::string why = doc.error; doc.undo(); doc.error = why; }
panel->mcp_after_change();
return json{{"ok", ok}, {"shell_index", s}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}};
}
json action_rib(DesignPanel* panel, const json& params)
{
if (!params.contains("sketch")) throw std::runtime_error("rib needs 'sketch' (feature index)");
if (!params.contains("entity")) throw std::runtime_error("rib needs 'entity' (entity index)");
CadDocument& doc = panel->mcp_doc();
if (doc.bodies.empty()) throw std::runtime_error("no body to rib");
int sketch = params["sketch"].get<int>();
int entity = params["entity"].get<int>();
double thickness = params.value("thickness", 2.0);
double depth = params.value("depth", 10.0);
int bi = target_body_arg(params, doc);
doc.checkpoint();
int idx = doc.add_rib(sketch, entity, thickness, depth, bi, "Rib");
bool ok = doc.recompute();
if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; }
panel->mcp_after_change();
return json{{"ok", ok}, {"rib_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}};
}
json action_surface_extrude(DesignPanel* panel, const json& params)
{
if (!params.contains("sketch")) throw std::runtime_error("surface_extrude needs 'sketch' (feature index)");
CadDocument& doc = panel->mcp_doc();
int sketch = params["sketch"].get<int>();
double distance = params.value("distance", 10.0);
doc.checkpoint();
int idx = doc.add_surface_extrude(sketch, distance, "SurfaceExtrude");
bool ok = doc.recompute();
if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; }
panel->mcp_after_change();
return json{{"ok", ok}, {"feature_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}};
}
json action_surface_revolve(DesignPanel* panel, const json& params)
{
if (!params.contains("sketch")) throw std::runtime_error("surface_revolve needs 'sketch' (feature index)");
CadDocument& doc = panel->mcp_doc();
int sketch = params["sketch"].get<int>();
double angle = params.value("angle", 360.0);
int axis = params.value("axis", 0);
doc.checkpoint();
int idx = doc.add_surface_revolve(sketch, angle, axis, "SurfaceRevolve");
bool ok = doc.recompute();
if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; }
panel->mcp_after_change();
return json{{"ok", ok}, {"feature_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}};
}
json action_thicken_surface(DesignPanel* panel, const json& params)
{
CadDocument& doc = panel->mcp_doc();
if (doc.bodies.empty()) throw std::runtime_error("no sheet body to thicken");
int bi = target_body_arg(params, doc);
double thickness = params.value("thickness", 2.0);
bool flip = params.value("flip", false);
doc.checkpoint();
int idx = doc.add_thicken_surface(bi, thickness, flip, "ThickenSurface");
bool ok = doc.recompute();
if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; }
panel->mcp_after_change();
return json{{"ok", ok}, {"feature_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}};
}
json action_surface_offset(DesignPanel* panel, const json& params)
{
CadDocument& doc = panel->mcp_doc();
if (doc.bodies.empty()) throw std::runtime_error("no sheet body to offset");
int bi = target_body_arg(params, doc);
double offset = params.value("offset", 1.0);
doc.checkpoint();
int idx = doc.add_surface_offset(bi, offset, "SurfaceOffset");
bool ok = doc.recompute();
if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; }
panel->mcp_after_change();
return json{{"ok", ok}, {"feature_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}};
}
json action_surface_loft(DesignPanel* panel, const json& params)
{
if (!params.contains("profiles") || !params["profiles"].is_array())
throw std::runtime_error("surface_loft needs 'profiles' (array of int feature indices)");
CadDocument& doc = panel->mcp_doc();
std::vector<int> profiles;
for (const json& j : params["profiles"]) profiles.push_back(j.get<int>());
bool ruled = params.value("ruled", false);
doc.checkpoint();
int idx = doc.add_surface_loft(profiles, ruled, "SurfaceLoft");
bool ok = doc.recompute();
if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; }
panel->mcp_after_change();
return json{{"ok", ok}, {"feature_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}};
}
json action_surface_fill(DesignPanel* panel, const json& params)
{
if (!params.contains("sketch")) throw std::runtime_error("surface_fill needs 'sketch' (feature index)");
CadDocument& doc = panel->mcp_doc();
int sketch = params["sketch"].get<int>();
doc.checkpoint();
int idx = doc.add_surface_fill(sketch, "SurfaceFill");
bool ok = doc.recompute();
if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; }
panel->mcp_after_change();
return json{{"ok", ok}, {"feature_index", idx}, {"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) { const std::string why = doc.error; doc.undo(); doc.error = why; }
panel->mcp_after_change();
return json{{"ok", ok}, {"draft_index", d}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}};
}
// ---- 2D sketch verbs --------------------------------------------------------------------
//
// The model is FreeCAD's Sketcher, adapted to this tab's right-click world. Three ideas are
// taken over deliberately:
//
// * GEOMETRY IS SEPARATE FROM CONSTRAINTS. You add curves, then you constrain them; the
// solver reports degrees of freedom and whether it is consistent. `sketch_describe` returns
// both halves plus the DoF, which is the whole state a caller needs to reason about.
// * A SKETCH IS JUDGED BY ITS LOOPS, not by its coordinates. FreeCAD asks whether the profile
// is closed before it will build from it; `sketch_describe` answers that directly, listing
// each closed loop, the loops it encloses as VOIDS, and — the actionable part — the exact
// plane coordinates where a chain is still open.
// * VALIDATE, THEN FIX. FreeCAD's ValidateSketch finds vertices that overlap within a
// tolerance but carry no coincidence, and adds the missing ones. `sketch_validate` reports
// them, `sketch_heal` welds and constrains them. That is what turns a loop that is closed by
// floating-point luck into one that is closed by construction and stays closed through
// every later solve.
//
// What is NOT taken over: FreeCAD's Sketcher is a modal dialog with its own toolbars. Here the
// vocabulary is the right-click offer, so these verbs are named after what the menu offers on a
// selection, and every one of them drives the SAME DesignSketchTool the mouse drives.
DesignSketchTool& mcp_sketch(DesignPanel* panel)
{
DesignCanvas* vp = panel->mcp_viewport();
if (vp == nullptr) throw std::runtime_error("no viewport");
if (!vp->is_sketching())
throw std::runtime_error("no sketch is open — call sketch_begin first");
return vp->mcp_sketch_tool();
}
SketchEntity sketch_entity_from(const json& j)
{
const std::string t = j.value("type", std::string(""));
SketchEntity e;
auto p = [&](const char* k, double dx, double dy) {
if (!j.contains(k)) return Vec2d(dx, dy);
const json& a = j.at(k);
if (!a.is_array() || a.size() < 2) throw std::runtime_error(std::string(k) + " must be [x, y]");
return Vec2d(a[0].get<double>(), a[1].get<double>());
};
e.construction = j.value("construction", false);
// A circle and an arc are defined BY their centre, so a request that does not carry one is
// incomplete, not a request for a circle at the origin. Defaulting it silently put geometry
// somewhere the caller never asked for and then reported perfectly consistent loops, areas
// and hole attribution ABOUT THAT WRONG GEOMETRY — which is far more expensive to disbelieve
// than an error would have been. `p0` is accepted as an alias because that is exactly what a
// circle stores internally (e.p0 = e.center below), so a caller who writes p0 means centre.
auto centre_of = [&](const char* what) {
if (j.contains("center")) return p("center", 0, 0);
if (j.contains("centre")) return p("centre", 0, 0);
if (j.contains("p0")) return p("p0", 0, 0);
throw std::runtime_error(std::string(what) + " needs a 'center' (or 'p0')");
};
if (t == "line") {
e.type = SketchEntity::Type::Line;
e.p0 = p("p0", 0, 0); e.p1 = p("p1", 0, 0);
} else if (t == "circle") {
e.type = SketchEntity::Type::Circle;
e.center = centre_of("circle");
e.radius = j.value("radius", 0.0);
e.p0 = e.center;
if (e.radius <= 0.0) throw std::runtime_error("circle needs a positive 'radius'");
} else if (t == "arc") {
e.type = SketchEntity::Type::Arc;
e.center = centre_of("arc");
e.radius = j.value("radius", 0.0);
e.start_angle = j.value("start_angle", 0.0);
e.end_angle = j.value("end_angle", 0.0);
if (e.radius <= 0.0) throw std::runtime_error("arc needs a positive 'radius'");
e.p0 = e.center + e.radius * Vec2d(std::cos(e.start_angle), std::sin(e.start_angle));
e.p1 = e.center + e.radius * Vec2d(std::cos(e.end_angle), std::sin(e.end_angle));
} else if (t == "point") {
e.type = SketchEntity::Type::Point;
e.p0 = p("p", 0, 0);
} else {
throw std::runtime_error("unknown entity type '" + t + "' (line, arc, circle, point)");
}
return e;
}
json sketch_entity_to(const SketchEntity& e, int index)
{
json j{{"index", index}, {"construction", e.construction}};
switch (e.type) {
case SketchEntity::Type::Line:
j["type"] = "line";
j["p0"] = json::array({e.p0.x(), e.p0.y()});
j["p1"] = json::array({e.p1.x(), e.p1.y()});
j["length"] = (e.p1 - e.p0).norm();
break;
case SketchEntity::Type::Circle:
j["type"] = "circle";
j["center"] = json::array({e.center.x(), e.center.y()});
j["radius"] = e.radius;
break;
case SketchEntity::Type::Arc:
j["type"] = "arc";
j["center"] = json::array({e.center.x(), e.center.y()});
j["radius"] = e.radius;
j["start_angle"] = e.start_angle;
j["end_angle"] = e.end_angle;
j["p0"] = json::array({e.p0.x(), e.p0.y()});
j["p1"] = json::array({e.p1.x(), e.p1.y()});
break;
case SketchEntity::Type::Point:
j["type"] = "point";
j["p"] = json::array({e.p0.x(), e.p0.y()});
break;
// Ellipses and splines used to serialise as a TYPE NAME and nothing else, so every
// parameter they have was invisible to the only read-back this project has. A ladder could
// count them and grade the faceted area of the loop they close (2e-2, the faceting error) —
// it could not check a single axis, angle or pole. "Precise definition of every aspect"
// cannot be asserted about an entity whose aspects the instrument cannot see.
case SketchEntity::Type::Ellipse:
j["type"] = "ellipse";
j["center"] = json::array({e.center.x(), e.center.y()});
j["radius"] = e.radius; // semi-major (a)
j["rminor"] = e.rminor; // semi-minor (b)
j["rotation"] = e.rotation; // major-axis angle, radians
break;
case SketchEntity::Type::EllipseArc:
j["type"] = "ellipse_arc";
j["center"] = json::array({e.center.x(), e.center.y()});
j["radius"] = e.radius;
j["rminor"] = e.rminor;
j["rotation"] = e.rotation;
j["start_angle"] = e.start_angle;
j["end_angle"] = e.end_angle;
j["p0"] = json::array({e.p0.x(), e.p0.y()});
j["p1"] = json::array({e.p1.x(), e.p1.y()});
break;
case SketchEntity::Type::BSpline: {
j["type"] = "spline";
json poles = json::array();
for (const Vec2d& c : e.ctrl) poles.push_back(json::array({c.x(), c.y()}));
j["ctrl"] = poles;
j["p0"] = json::array({e.p0.x(), e.p0.y()});
j["p1"] = json::array({e.p1.x(), e.p1.y()});
break;
}
}
return j;
}
// Which entities a verb acts on: an explicit "entities" array, else the current selection,
// else — only where the verb says so — everything. Same precedence the menu uses: what you
// pointed at wins, and the menu never silently acts on the whole sketch.
std::vector<int> sketch_targets(const json& params, DesignSketchTool& t, bool all_if_empty)
{
std::vector<int> out;
if (params.contains("entities")) {
for (const auto& v : params.at("entities")) out.push_back(v.get<int>());
return out;
}
out = t.selection();
if (out.empty() && all_if_empty)
for (int i = 0; i < int(t.entities().size()); ++i) out.push_back(i);
return out;
}
json action_sketch_begin(DesignPanel* panel, const json& params)
{
DesignCanvas* vp = panel->mcp_viewport();
if (vp == nullptr) throw std::runtime_error("no viewport");
if (vp->is_sketching()) throw std::runtime_error("a sketch is already open");
const std::string pl = params.value("plane", std::string("XY"));
SketchPlane plane = SketchPlane::XY();
if (pl == "XZ") plane = SketchPlane::XZ();
else if (pl == "YZ") plane = SketchPlane::YZ();
else if (pl != "XY") throw std::runtime_error("plane must be XY, XZ or YZ");
vp->begin_sketch(plane, DesignSketchTool::Mode::Select);
// The panel has to enter sketch mode too, or the app is half in it: the tool sketches, the
// offer menu offers sketch verbs, and every sketch KEY is dead because key dispatch tests
// m_ui_mode while the menu tests the viewport. Driving the socket must leave the GUI in the
// state a user would be in, not a state only the socket can produce.
panel->mcp_set_sketch_mode(true);
return json{{"ok", true}, {"plane", pl}};
}
json action_sketch_commit(DesignPanel* panel, const json& params)
{
(void)params;
DesignCanvas* vp = panel->mcp_viewport();
if (vp == nullptr || !vp->is_sketching()) throw std::runtime_error("no sketch is open");
vp->finish_sketch();
panel->mcp_set_sketch_mode(false);
panel->mcp_after_change();
return json{{"ok", true}, {"features", int(panel->mcp_doc().features.size())}};
}
json action_sketch_cancel(DesignPanel* panel, const json& params)
{
(void)params;
DesignCanvas* vp = panel->mcp_viewport();
if (vp == nullptr || !vp->is_sketching()) throw std::runtime_error("no sketch is open");
vp->cancel_sketch();
panel->mcp_set_sketch_mode(false);
return json{{"ok", true}};
}
json action_sketch_add(DesignPanel* panel, const json& params)
{
DesignSketchTool& t = mcp_sketch(panel);
std::vector<SketchEntity> ents;
if (params.contains("entities")) {
for (const auto& j : params.at("entities")) ents.push_back(sketch_entity_from(j));
} else if (params.contains("type")) {
ents.push_back(sketch_entity_from(params)); // single-entity shorthand
} else if (params.contains("rect")) {
// Corner rectangle as four shared-endpoint lines, so it arrives as ONE closed loop
// rather than four segments that happen to touch.
const json& r = params.at("rect");
if (!r.is_array() || r.size() < 4) throw std::runtime_error("rect must be [x0, y0, x1, y1]");
const double x0 = r[0].get<double>(), y0 = r[1].get<double>();
const double x1 = r[2].get<double>(), y1 = r[3].get<double>();
const bool c = params.value("construction", false);
auto seg = [&](Vec2d a, Vec2d b) { SketchEntity e; e.type = SketchEntity::Type::Line;
e.p0 = a; e.p1 = b; e.construction = c; return e; };
ents.push_back(seg({x0, y0}, {x1, y0}));
ents.push_back(seg({x1, y0}, {x1, y1}));
ents.push_back(seg({x1, y1}, {x0, y1}));
ents.push_back(seg({x0, y1}, {x0, y0}));
} else {
throw std::runtime_error("sketch_add needs 'entities', a single 'type', or 'rect'");
}
const int base = t.add_entities_scripted(ents);
panel->mcp_viewport()->request_repaint();
return json{{"ok", base >= 0}, {"first_index", base}, {"added", int(ents.size())},
{"entities", int(t.entities().size())}, {"dof", t.dof()}};
}
json action_sketch_select(DesignPanel* panel, const json& params)
{
DesignSketchTool& t = mcp_sketch(panel);
std::vector<int> idx;
if (params.contains("entities"))
for (const auto& v : params.at("entities")) idx.push_back(v.get<int>());
const bool any = t.select_indices(idx);
panel->mcp_viewport()->request_repaint();
return json{{"ok", true}, {"selected", int(t.selection().size())}, {"any", any}};
}
json action_sketch_delete(DesignPanel* panel, const json& params)
{
DesignSketchTool& t = mcp_sketch(panel);
const std::vector<int> tgt = sketch_targets(params, t, false);
if (tgt.empty()) throw std::runtime_error("nothing selected and no 'entities' given");
const int before = int(t.entities().size());
t.select_indices(tgt);
t.delete_selected();
panel->mcp_viewport()->request_repaint();
return json{{"ok", true}, {"removed", before - int(t.entities().size())},
{"entities", int(t.entities().size())}};
}
json action_sketch_construction(DesignPanel* panel, const json& params)
{
DesignSketchTool& t = mcp_sketch(panel);
const std::vector<int> tgt = sketch_targets(params, t, false);
if (tgt.empty()) throw std::runtime_error("nothing selected and no 'entities' given");
t.select_indices(tgt);
const int n = t.toggle_selection_construction();
panel->mcp_viewport()->request_repaint();
return json{{"ok", n > 0}, {"changed", n}};
}
json action_sketch_offset(DesignPanel* panel, const json& params)
{
if (!params.contains("distance")) throw std::runtime_error("sketch_offset needs 'distance'");
DesignSketchTool& t = mcp_sketch(panel);
const double d = params.at("distance").get<double>();
const std::vector<int> tgt = sketch_targets(params, t, true);
std::vector<SketchEntity> src;
for (int i : tgt)
if (i >= 0 && i < int(t.entities().size())) src.push_back(t.entities()[i]);
if (src.empty()) throw std::runtime_error("nothing to offset");
const auto out = SketchEngine::offset_entities(src, d);
if (out.empty()) throw std::runtime_error("offset produced nothing (ellipses and splines are not offset)");
const int base = t.add_entities_scripted(out);
panel->mcp_viewport()->request_repaint();
return json{{"ok", true}, {"first_index", base}, {"added", int(out.size())}, {"dof", t.dof()}};
}
json action_sketch_mirror(DesignPanel* panel, const json& params)
{
DesignSketchTool& t = mcp_sketch(panel);
auto pt = [&](const char* k, double dx, double dy) {
if (!params.contains(k)) return Vec2d(dx, dy);
const json& a = params.at(k);
if (!a.is_array() || a.size() < 2) throw std::runtime_error(std::string(k) + " must be [x, y]");
return Vec2d(a[0].get<double>(), a[1].get<double>());
};
const Vec2d a = pt("axis_a", 0, 0), b = pt("axis_b", 0, 1);
const std::vector<int> tgt = sketch_targets(params, t, true);
std::vector<SketchEntity> src;
for (int i : tgt)
if (i >= 0 && i < int(t.entities().size())) src.push_back(t.entities()[i]);
if (src.empty()) throw std::runtime_error("nothing to mirror");
const auto out = SketchEngine::mirror_entities(src, a, b);
const int base = t.add_entities_scripted(out);
panel->mcp_viewport()->request_repaint();
return json{{"ok", true}, {"first_index", base}, {"added", int(out.size())}, {"dof", t.dof()}};
}
json sketch_report(DesignSketchTool& t)
{
const auto rep = t.loop_report();
json loops = json::array();
for (const auto& l : rep.loops) {
json holes = json::array();
for (int h : l.holes) holes.push_back(h);
loops.push_back(json{{"entities", l.ents}, {"holes", holes},
{"closed", l.closed}, {"area", l.area}});
}
json open_ends = json::array();
for (const Vec2d& p : rep.open_ends) open_ends.push_back(json::array({p.x(), p.y()}));
// A profile is buildable when at least one loop closed, nothing is left dangling and no loop
// crosses or folds back on itself.
json defects = json::array();
for (const auto& l : rep.loops)
if (l.defect) defects.push_back(json::array({l.defect_at.x(), l.defect_at.y()}));
const bool buildable = !rep.loops.empty() && rep.open_ends.empty() && defects.empty();
return json{{"closed_loops", loops}, {"open_ends", open_ends}, {"defects", defects}, {"buildable", buildable}};
}
json action_sketch_describe(DesignPanel* panel, const json& params)
{
(void)params;
DesignSketchTool& t = mcp_sketch(panel);
json ents = json::array();
for (int i = 0; i < int(t.entities().size()); ++i)
ents.push_back(sketch_entity_to(t.entities()[i], i));
// The armed TOOL and its pending anchors. Without these the only way to tell which tool a
// menu row actually armed is to draw with it and infer from what came out — which is how a
// menu walk that lands one row off gets diagnosed as "the tool is broken".
static const char* const kModeNames[] = {
"select", "dimension", "polyline", "line", "rect_corner", "rect_center", "rect_oblique",
"rect_rounded", "circle_center", "circle_2pt", "point",
"circle_3pt", "arc_3pt", "arc_tangent", "arc_center", "slot", "slot_arc", "polygon",
"ellipse", "ellipse_arc", "spline",
"fillet", "chamfer", "offset", "mirror",
"trim", "extend",
"move", "rotate", "scale", "array", "array_polar",
"transform_art",
"constrain" };
const int mi = int(t.mode());
json out{{"ok", true},
{"entities", ents},
{"constraints", int(t.constraints().size())},
{"dof", t.dof()},
{"solve_ok", t.solve_ok()},
{"tool", (mi >= 0 && mi < int(sizeof(kModeNames) / sizeof(kModeNames[0])))
? kModeNames[mi] : "unknown"},
{"pending", t.pending_points()},
{"editing", t.value_field_open()},
{"selection", t.selection()}};
out.update(sketch_report(t));
return out;
}
json action_sketch_validate(DesignPanel* panel, const json& params)
{
DesignSketchTool& t = mcp_sketch(panel);
const double tol = params.value("tolerance", 1e-3);
json out{{"ok", true}, {"tolerance", tol}, {"dof", t.dof()}, {"solve_ok", t.solve_ok()}};
out.update(sketch_report(t));
return out;
}
json action_sketch_heal(DesignPanel* panel, const json& params)
{
DesignSketchTool& t = mcp_sketch(panel);
const double tol = params.value("tolerance", 1e-3);
const bool ic = params.value("ignore_construction", true);
const int welded = t.heal_coincidences(tol, ic);
panel->mcp_viewport()->request_repaint();
json out{{"ok", true}, {"welded", welded}, {"tolerance", tol},
{"dof", t.dof()}, {"solve_ok", t.solve_ok()}};
out.update(sketch_report(t));
return out;
}
// Why sketch_set_value exists: committing a typed dimension could previously only be exercised
// by driving the in-canvas value field, and the rig's window manager never gives that frame
// keyboard focus, so the behaviour of apply_dimension on CONSTRAINED geometry was untestable.
// This calls the same function the widget calls, so the geometry can be asserted with no window
// manager involved. Note apply_dimension clears the selection.
json action_sketch_set_value(DesignPanel* panel, const json& params)
{
DesignSketchTool& t = mcp_sketch(panel);
if (!params.contains("value")) throw std::runtime_error("sketch_set_value needs 'value'");
const double value = params["value"].get<double>();
const DesignSketchTool::DimType kind = t.dimension_kind();
if (kind == DesignSketchTool::DimType::None)
throw std::runtime_error("the selection has no value to set — pick a line, an arc, a circle, or two entities");
const char* kind_name = "none";
switch (kind) {
case DesignSketchTool::DimType::Length: kind_name = "length"; break;
case DesignSketchTool::DimType::Radius: kind_name = "radius"; break;
case DesignSketchTool::DimType::Diameter: kind_name = "diameter"; break;
case DesignSketchTool::DimType::Angle: kind_name = "angle"; break;
case DesignSketchTool::DimType::Distance: kind_name = "distance"; break;
case DesignSketchTool::DimType::DistanceToLine: kind_name = "distance_to_line"; break;
default: break;
}
// Validate BEFORE apply_dimension, at the socket boundary. The tool only MOVES geometry when
// the value passes its own per-case thresholds, but it records the driving constraint
// UNCONDITIONALLY afterwards — a negative/zero/NaN value that moved nothing would still be
// pushed to the solver as a constraint it must satisfy and cannot, silently corrupting the
// sketch rather than failing. (apply_dimension has the same flaw for any other caller; this
// guard protects the socket, not the tool.) Fail loudly instead of recording a poison value.
if (!std::isfinite(value))
throw std::runtime_error("dimension must be finite — NaN or infinity is not a dimension");
switch (kind) {
case DesignSketchTool::DimType::Length:
case DesignSketchTool::DimType::Radius:
case DesignSketchTool::DimType::Diameter:
if (value <= 0.0)
throw std::runtime_error(std::string(kind_name) + " must be positive (got " + std::to_string(value) + ")");
break;
case DesignSketchTool::DimType::Distance:
case DesignSketchTool::DimType::DistanceToLine:
if (value < 0.0)
throw std::runtime_error(std::string(kind_name) + " must be >= 0 (zero means coincident / on the line)");
break;
case DesignSketchTool::DimType::Angle:
default:
break; // any finite angle is valid
}
const double before = t.dimension_current();
t.apply_dimension(value);
panel->mcp_viewport()->request_repaint();
json out{{"ok", true}, {"kind", kind_name}, {"before", before}, {"value", value},
{"dof", t.dof()}, {"solve_ok", t.solve_ok()}};
out.update(sketch_report(t));
return out;
}
// Verbs whose handler opens a MODAL dialog. This matters more than it looks: the socket thread
// posts the call to the wx main thread and waits 15 s on a future, so a verb that blocks that
// thread inside ShowModal() times the RPC out AND leaves the main thread blocked until a human
// dismisses the dialog — every later call then times out too, 15 s at a time. Which is exactly
// the trap for the deck user this surface exists for: one button press and the app is modal,
// waiting for a mouse they may not be reaching for.
//
// So run_verb NEVER dispatches inline. The list here is only so list_verbs can label the keys
// that will want a mouse; re-derive it with
// grep -n ShowModal src/slic3r/GUI/CAD/DesignPanel.cpp
// and map each handler back to its action string in DesignOffer.hpp.
bool verb_is_modal(const char* id)
{
static const char* kModal[] = { "sk_text", "sk_svg", "colour" };
for (const char* m : kModal)
if (std::strcmp(m, id) == 0) return true;
return false;
}
// Why list_verbs exists: the deck profile in VSD_n1_streamcontroller is built by parsing
// DesignPanel's key tables out of the SOURCE, so a verb without a keyboard shortcut is invisible
// to it. Serving the whole offer table over the socket lets the profile be generated from the
// running app instead, and lets a deck key name a verb rather than spend a letter.
json action_list_verbs(DesignPanel* panel, const json& params)
{
const int kind = panel->mcp_offer_selection_kind();
const uint32_t bit = offer_bit(OfferSel(kind));
const bool applicable_only = params.value("applicable_only", false);
const bool has_sketch_mode = params.contains("sketch_mode");
const bool want_sketch_mode = has_sketch_mode && params["sketch_mode"].get<bool>();
json verbs = json::array();
for (int i = 0; i < kOfferVerbCount; ++i) {
const OfferVerb& v = kOfferVerbs[i];
const bool applies = (v.accepts & bit) != 0;
if (applicable_only && !applies) continue;
if (has_sketch_mode && v.sketch_mode != want_sketch_mode) continue;
// Verbs whose action is nullptr exist in the vocabulary but have no GUI path yet — report
// them with a null action rather than dropping them.
verbs.push_back(json{
{"id", v.id},
{"name", v.name},
{"row", v.row},
{"row_name", kOfferRowNames[v.row]},
{"key", v.key ? json(v.key) : json(nullptr)},
{"action", v.action ? json(v.action) : json(nullptr)},
{"sketch_mode", v.sketch_mode},
{"applies", applies},
{"modal", verb_is_modal(v.id)}, // opening a dialog: this key will want a mouse
{"hint", v.hint ? json(v.hint) : json(nullptr)},
});
}
return json{{"ok", true}, {"selection_kind", kind}, {"count", verbs.size()},
{"verbs", std::move(verbs)}};
}
json action_run_verb(DesignPanel* panel, const json& params)
{
if (!params.contains("verb")) throw std::runtime_error("run_verb needs 'verb' (an offer verb id)");
const std::string verb = params["verb"].get<std::string>();
// Scan the offer table BEFORE dispatching so the failure message can tell an unknown id from
// one that exists in the vocabulary but has no GUI path (action == nullptr).
bool known = false;
bool has_action = false;
const char* action = nullptr;
for (int i = 0; i < kOfferVerbCount; ++i) {
if (std::strcmp(kOfferVerbs[i].id, verb.c_str()) == 0) {
known = true;
has_action = (kOfferVerbs[i].action != nullptr);
action = kOfferVerbs[i].action;
break;
}
}
if (!known) throw std::runtime_error("run_verb: unknown verb '" + verb + "'");
if (!has_action) throw std::runtime_error("run_verb: '" + verb + "' has no GUI path yet");
// Whether the verb would be OFFERED for the current selection. Not a refusal: the GUI lets
// you press a tool's shortcut whatever is selected, and refusing here would make the socket
// stricter than the keyboard for no reason. Reported so a caller can tell "did nothing
// because it did not apply" from "did nothing because it is broken".
const int kind = panel->mcp_offer_selection_kind();
const uint32_t bit = offer_bit(OfferSel(kind));
bool applies = false;
for (int i = 0; i < kOfferVerbCount; ++i)
if (std::strcmp(kOfferVerbs[i].id, verb.c_str()) == 0) { applies = (kOfferVerbs[i].accepts & bit) != 0; break; }
// The rule is not "validate more", it is "the socket should offer exactly what the GUI
// offers, no more and no less". A "btn:"/"fly:" verb is reachable in the GUI ONLY through
// the offer menu, which GREYS its row when it does not apply to the current selection — so
// a socket caller must not be able to fire it either. But a "key:" verb is reachable from
// the KEYBOARD whatever is selected, and the app permits that, so refusing it would make
// the socket stricter than the keyboard for no reason. Refuse only the menu-only verbs.
if (!applies && action &&
(std::strncmp(action, "btn:", 4) == 0 || std::strncmp(action, "fly:", 4) == 0)) {
throw std::runtime_error("run_verb: '" + verb +
"' does not apply to the current selection (selection_kind " +
std::to_string(kind) + ")");
}
// Dispatch on the NEXT turn of the event loop, never inline. See verb_is_modal above: a verb
// that opens a dialog would otherwise block the thread this call is running on. Deferring
// costs the ability to report the verb's outcome — which run_offer_action never returned
// anyway — and buys a socket that cannot be wedged by any verb in the table.
wxGetApp().CallAfter([panel, verb]() { panel->mcp_run_verb(verb.c_str()); });
return json{{"ok", true}, {"verb", verb}, {"dispatched", true},
{"applies", applies}, {"modal", verb_is_modal(verb.c_str())},
{"selection_kind", kind}};
}
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) { const std::string why = doc.error; doc.undo(); doc.error = why; }
panel->mcp_after_change();
return json{{"ok", ok}, {"mirror_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}};
}
json action_transform(DesignPanel* panel, const json& params)
{
CadDocument& doc = panel->mcp_doc();
if (doc.bodies.empty()) throw std::runtime_error("no body to transform");
int bi = target_body_arg(params, doc);
Vec3d translate(params.value("dx", 0.0), params.value("dy", 0.0), params.value("dz", 0.0));
Vec3d axis(params.value("axis_x", 0.0), params.value("axis_y", 0.0), params.value("axis_z", 1.0));
Vec3d pivot(params.value("pivot_x", 0.0), params.value("pivot_y", 0.0), params.value("pivot_z", 0.0));
double angle = params.value("angle", 0.0);
bool copy = params.value("copy", false);
doc.checkpoint();
int idx = doc.add_transform(bi, translate, axis, pivot, angle, copy, "Transform");
bool ok = doc.recompute();
if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; }
panel->mcp_after_change();
return json{{"ok", ok}, {"transform_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}};
}
json action_thicken(DesignPanel* panel, const json& params)
{
if (!params.contains("face")) throw std::runtime_error("thicken needs 'face' (id from query_topology)");
CadDocument& doc = panel->mcp_doc();
if (doc.bodies.empty()) throw std::runtime_error("no body to thicken");
int bi = target_body_arg(params, doc);
int face = params["face"].get<int>();
double thickness = params.value("thickness", 2.0);
bool flip = params.value("flip", false);
doc.checkpoint();
int idx = doc.add_thicken(bi, face, thickness, flip, "Thicken");
bool ok = doc.recompute();
if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; }
panel->mcp_after_change();
return json{{"ok", ok}, {"thicken_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}};
}
json action_split(DesignPanel* panel, const json& params)
{
if (!params.contains("face")) throw std::runtime_error("split needs 'face' (id from query_topology)");
CadDocument& doc = panel->mcp_doc();
if (doc.bodies.empty()) throw std::runtime_error("no body to split");
int bi = target_body_arg(params, doc);
int face_body = params.value("face_body", -1);
int face = params["face"].get<int>();
bool keep_upper = params.value("keep_upper", true);
bool keep_lower = params.value("keep_lower", true);
doc.checkpoint();
int idx = doc.add_split_by_face(bi, face_body, face, keep_upper, keep_lower, "Split");
bool ok = doc.recompute();
if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; }
panel->mcp_after_change();
return json{{"ok", ok}, {"split_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}};
}
json action_project(DesignPanel* panel, const json& params)
{
CadDocument& doc = panel->mcp_doc();
if (doc.bodies.empty()) throw std::runtime_error("no source body to project from");
int source_body = params.value("source_body", -1);
int face = params.value("face", -1);
std::vector<int> edges;
if (params.contains("edges") && params["edges"].is_array())
for (const auto& v : params["edges"]) edges.push_back(v.get<int>());
SketchPlane pl = plane_from(params, doc);
doc.checkpoint();
int idx = doc.add_project_edges(source_body, edges, face, pl, "Project");
bool ok = doc.recompute();
if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; }
panel->mcp_after_change();
return json{{"ok", ok}, {"project_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}};
}
json action_delete_face(DesignPanel* panel, const json& params)
{
if (!params.contains("faces")) throw std::runtime_error("delete_face needs 'faces' (array of face ids)");
CadDocument& doc = panel->mcp_doc();
if (doc.bodies.empty()) throw std::runtime_error("no body to delete faces from");
int bi = target_body_arg(params, doc);
std::vector<int> faces;
if (params["faces"].is_array())
for (const auto& v : params["faces"]) faces.push_back(v.get<int>());
doc.checkpoint();
int idx = doc.add_delete_face(bi, faces, "DeleteFace");
bool ok = doc.recompute();
if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; }
panel->mcp_after_change();
return json{{"ok", ok}, {"feature_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}};
}
json action_bridge(DesignPanel* panel, const json& params)
{
if (!params.contains("sketch")) throw std::runtime_error("bridge needs 'sketch' (feature index)");
if (!params.contains("ent_a")) throw std::runtime_error("bridge needs 'ent_a' (entity index)");
if (!params.contains("ent_b")) throw std::runtime_error("bridge needs 'ent_b' (entity index)");
int sketch = params["sketch"].get<int>();
int ent_a = params["ent_a"].get<int>();
int ent_b = params["ent_b"].get<int>();
int end_a = params.value("end_a", 1);
int end_b = params.value("end_b", 0);
CadDocument& doc = panel->mcp_doc();
doc.checkpoint();
int ei = doc.add_bridge(sketch, ent_a, end_a, ent_b, end_b, "Bridge");
bool ok = doc.recompute();
if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; }
panel->mcp_after_change();
return json{{"ok", ok}, {"sketch_index", sketch}, {"entity_index", ei},
{"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}};
}
json action_mate(DesignPanel* panel, const json& params)
{
if (!params.contains("cs_a")) throw std::runtime_error("mate needs 'cs_a' (CoordSys feature index)");
if (!params.contains("cs_b")) throw std::runtime_error("mate needs 'cs_b' (CoordSys feature index)");
const int kind = params.value("kind", 0);
const int cs_a = params["cs_a"].get<int>();
const int cs_b = params["cs_b"].get<int>();
const double offset = params.value("offset", 0.0);
const double angle = params.value("angle", 0.0);
const bool flip = params.value("flip", false);
CadDocument& doc = panel->mcp_doc();
doc.checkpoint();
int idx = doc.add_mate(kind, cs_a, cs_b, offset, angle, flip, "Mate");
bool ok = doc.recompute();
if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; }
panel->mcp_after_change();
return json{{"ok", ok}, {"mate_index", idx}, {"bodies", int(doc.bodies.size())}, {"error", doc.error}};
}
json action_check_interference(DesignPanel* panel, const json& params)
{
const double min_volume = params.value("min_volume", 1e-6);
CadDocument& doc = panel->mcp_doc();
// Read-only: no checkpoint(), no recompute(), no mcp_after_change().
const auto hits = doc.check_interference(min_volume);
json arr = json::array();
for (const auto& h : hits) {
arr.push_back(json{{"body_a", h.body_a}, {"body_b", h.body_b}, {"volume", h.volume}});
}
return json{{"ok", true}, {"count", int(hits.size())}, {"interferences", arr}};
}
json action_set_variable(DesignPanel* panel, const json& params)
{
if (!params.contains("name")) throw std::runtime_error("set_variable needs 'name'");
if (!params.contains("expr")) throw std::runtime_error("set_variable needs 'expr'");
CadDocument& doc = panel->mcp_doc();
std::string name = params["name"].get<std::string>();
std::string expr = params["expr"].get<std::string>();
std::string old = doc.variables.count(name) ? doc.variables[name] : "";
doc.checkpoint();
doc.variables[name] = expr;
bool ok = doc.recompute();
// undo() recomputes, which succeeds and clears doc.error; carry the reason across so
// the JSON reply below reports why the edit was rejected instead of an empty string.
if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; }
panel->mcp_after_change();
return json{{"ok", ok}, {"name", name}, {"error", doc.error}};
}
json action_set_feature_expr(DesignPanel* panel, const json& params)
{
if (!params.contains("feature")) throw std::runtime_error("set_feature_expr needs 'feature' index");
if (!params.contains("field")) throw std::runtime_error("set_feature_expr needs 'field' name");
if (!params.contains("expr")) throw std::runtime_error("set_feature_expr needs 'expr' string");
CadDocument& doc = panel->mcp_doc();
int fi = params["feature"].get<int>();
if (fi < 0 || fi >= (int)doc.features.size())
return json{{"ok", false}, {"error", "feature index out of range"}};
std::string field = params["field"].get<std::string>();
std::string expr = params["expr"].get<std::string>();
std::string old = doc.features[fi].expr.count(field) ? doc.features[fi].expr[field] : "";
doc.checkpoint();
doc.features[fi].expr[field] = expr;
bool ok = doc.recompute();
// undo() recomputes, which succeeds and clears doc.error; carry the reason across so
// the JSON reply below reports why the edit was rejected instead of an empty string.
if (!ok) { const std::string why = doc.error; doc.undo(); doc.error = why; }
panel->mcp_after_change();
return json{{"ok", ok}, {"feature", fi}, {"field", field}, {"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)
{
// The panel is built on first use, and in a headless session nobody clicks the tab that
// would build it -- so build it here rather than refusing. Safe: this runs on the main
// thread (see the CallAfter that dispatches us).
DesignPanel* panel = DesignPanel::ensure();
if (!panel)
return rpc_error(id, -32001, "Design panel not ready");
// A project opened without ever showing the Design tab has its recipe only in the Model;
// load it, as showing the tab would, before anything reads or writes the document.
panel->hydrate_from_model();
// Methods that only LOOK. Everything else changes the document or the live sketch, and
// must not do it under a GUI editor, a sketch session it does not own, or a rebuild in
// progress (the GUI's worker thread holds the document then).
static const std::set<std::string> kReadOnly = {
"describe_tools", "describe_scene", "query_topology", "measure", "mass_properties",
"slice_body", "validate_against", "list_verbs", "sketch_describe", "sketch_validate",
"check_interference" };
if (kReadOnly.count(method) == 0) {
std::string why;
if (panel->mcp_busy(method.rfind("sketch_", 0) == 0, why))
return rpc_error(id, -32002, "Design tab busy: " + why);
}
// Stale-id guard, checked here rather than in each handler.
//
// Global face and edge ids are indices into TopExp::MapShapes, valid only against the
// topology that produced them. Every dress-up rewrites those maps, so the natural way to
// drive this socket — read the scene once, then issue several operations — silently
// addresses the WRONG edge on every call after the first. Measured on a box: four chamfers
// with ids re-read each time remove 0.400/0.397/0.397/0.395 mm3; the same four with ids
// captured up front remove 0.400/0.008/0.397/0.280. Neither run errors, because a stale id
// still resolves to a real edge — just not the one that was asked for.
//
// So a caller may pass back the "generation" it got from describe_scene or query_topology,
// and a mismatch is refused instead of silently obeyed. Optional by design: omitting it
// keeps every existing script working exactly as before, and supplying it is what buys the
// guarantee. One check at the dispatcher rather than one per handler, so a method added
// later cannot forget it.
//
// The type check is not decoration: this runs OUTSIDE the try below, and a bare
// get<uint64_t>() on `"generation": "x"` throws nlohmann::type_error straight out of
// the CallAfter lambda that invoked us — through a wx event loop, which does not catch,
// so the whole GUI went down on one malformed line. Refuse it as a parameter error.
if (params.is_object() && params.contains("generation")) {
if (!params["generation"].is_number_unsigned())
return rpc_error(id, -32602, "generation must be an unsigned integer");
const uint64_t want = params["generation"].get<uint64_t>();
const uint64_t have = panel->mcp_doc().topo_generation;
if (want != have)
return rpc_error(id, -32010,
"stale face/edge ids: they were read at generation " + std::to_string(want)
+ " but the model is now at " + std::to_string(have)
+ ". Re-read query_topology and use the new ids — the old ones still name real "
"edges, just not the ones you measured.");
}
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 == "hole_styled") return rpc_result(id, action_hole_styled(panel, params));
if (method == "hole_standard") return rpc_result(id, action_hole_standard(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 == "pattern_on_curve") return rpc_result(id, action_pattern_on_curve(panel, params));
if (method == "shell") return rpc_result(id, action_shell(panel, params));
if (method == "rib") return rpc_result(id, action_rib(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 == "sketch_begin") return rpc_result(id, action_sketch_begin(panel, params));
if (method == "sketch_commit") return rpc_result(id, action_sketch_commit(panel, params));
if (method == "sketch_cancel") return rpc_result(id, action_sketch_cancel(panel, params));
if (method == "sketch_add") return rpc_result(id, action_sketch_add(panel, params));
if (method == "sketch_select") return rpc_result(id, action_sketch_select(panel, params));
if (method == "sketch_delete") return rpc_result(id, action_sketch_delete(panel, params));
if (method == "sketch_construction") return rpc_result(id, action_sketch_construction(panel, params));
if (method == "sketch_offset") return rpc_result(id, action_sketch_offset(panel, params));
if (method == "sketch_mirror") return rpc_result(id, action_sketch_mirror(panel, params));
if (method == "sketch_describe") return rpc_result(id, action_sketch_describe(panel, params));
if (method == "sketch_validate") return rpc_result(id, action_sketch_validate(panel, params));
if (method == "sketch_heal") return rpc_result(id, action_sketch_heal(panel, params));
if (method == "sketch_set_value") return rpc_result(id, action_sketch_set_value(panel, params));
if (method == "list_verbs") return rpc_result(id, action_list_verbs(panel, params));
if (method == "run_verb") return rpc_result(id, action_run_verb(panel, params));
if (method == "transform") return rpc_result(id, action_transform(panel, params));
if (method == "thicken") return rpc_result(id, action_thicken(panel, params));
if (method == "split") return rpc_result(id, action_split(panel, params));
if (method == "project") return rpc_result(id, action_project(panel, params));
if (method == "delete_face") return rpc_result(id, action_delete_face(panel, params));
if (method == "bridge") return rpc_result(id, action_bridge(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));
if (method == "set_variable") return rpc_result(id, action_set_variable(panel, params));
if (method == "set_feature_expr") return rpc_result(id, action_set_feature_expr(panel, params));
if (method == "surface_extrude") return rpc_result(id, action_surface_extrude(panel, params));
if (method == "surface_revolve") return rpc_result(id, action_surface_revolve(panel, params));
if (method == "thicken_surface") return rpc_result(id, action_thicken_surface(panel, params));
if (method == "surface_offset") return rpc_result(id, action_surface_offset(panel, params));
if (method == "surface_loft") return rpc_result(id, action_surface_loft(panel, params));
if (method == "surface_fill") return rpc_result(id, action_surface_fill(panel, params));
if (method == "mate") return rpc_result(id, action_mate(panel, params));
if (method == "check_interference") return rpc_result(id, action_check_interference(panel, params));
return rpc_error(id, -32601, "Unknown method: " + method);
} catch (const Standard_Failure& ex) { // derives from std::exception, so it comes first
return rpc_error(id, -32000, std::string("OCCT: ") + (*ex.what() ? ex.what() : "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();
// 0 = queued, 1 = running, 2 = abandoned. A request that timed out while still QUEUED must
// never run: the client has been told it failed, and a retry would otherwise apply it twice.
auto state = std::make_shared<std::atomic<int>>(0);
// Nothing may escape this lambda. It is invoked by the wx event loop, which has no
// handler of its own, so an escaping exception is std::terminate — the socket would
// become a way for any client to kill the application. handle_on_main() catches what
// it knows about; this catches what it does not, and still answers the caller.
wxGetApp().CallAfter([prom, state, method, params, id]() {
int queued = 0;
if (!state->compare_exchange_strong(queued, 1)) return; // abandoned by a timeout
try {
prom->set_value(handle_on_main(method, params, id));
} catch (const std::exception& ex) {
prom->set_value(rpc_error(id, -32000, std::string("internal error: ") + ex.what()));
} catch (...) {
prom->set_value(rpc_error(id, -32000, "internal error: unknown exception"));
}
});
if (fut.wait_for(std::chrono::seconds(15)) != std::future_status::ready) {
int queued = 0;
if (state->compare_exchange_strong(queued, 2))
return rpc_error(id, -32000, "main-thread timeout: the command was NOT run");
// Already running: it will finish, so wait for its real answer rather than report a
// failure for a command that is in fact being applied.
fut.wait();
}
return fut.get();
}
// Read newline-delimited requests off one client connection until EOF. A line may not grow
// past kMaxLine: a client that never sends a newline would otherwise grow this buffer until the
// process runs out of memory.
void serve_client(int cfd)
{
constexpr size_t kMaxLine = 16u << 20; // 16 MB — far above any real request
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));
if (buf.size() > kMaxLine && buf.find('\n') == std::string::npos) {
BOOST_LOG_TRIVIAL(error) << "MCP: request line over " << kMaxLine << " bytes; closing the connection";
return;
}
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');
// Never a bare write(): a client that hangs up between its request and our
// reply raises SIGPIPE, whose default action kills the process — so closing
// a socket mid-call would take the GUI with it.
#ifdef MSG_NOSIGNAL
if (::send(cfd, reply.data(), reply.size(), MSG_NOSIGNAL) < 0) return;
#else
if (::write(cfd, reply.data(), reply.size()) < 0) return; // SO_NOSIGPIPE set at accept
#endif
}
}
}
// Where the socket lives, for the exit handler.
std::string g_sock_path;
void remove_socket_at_exit()
{
struct stat st{};
if (!g_sock_path.empty() && ::lstat(g_sock_path.c_str(), &st) == 0 && S_ISSOCK(st.st_mode))
::unlink(g_sock_path.c_str());
}
void server_thread(std::string sock_path)
{
// Clear a stale socket from an earlier run — and ONLY a socket. ORCA_CAD_MCP names a path,
// and unlinking it unconditionally deleted whatever file that path happened to be.
{
struct stat st{};
if (::lstat(sock_path.c_str(), &st) == 0) {
if (!S_ISSOCK(st.st_mode)) {
BOOST_LOG_TRIVIAL(error) << "MCP: " << sock_path << " exists and is not a socket; refusing to replace it";
return;
}
::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);
// The socket is the full CAD command surface, including import_step on absolute paths.
// It lands in a world-writable directory by default (/tmp), so its access control is
// its file mode and nothing else — leaving that to the ambient umask means any local
// process may drive the modeller. umask around bind() makes it 0600 with no window in
// which a wider mode exists; the chmod afterwards covers platforms that do not apply
// umask to sockets.
const mode_t old_umask = ::umask(0177);
const int bind_rc = ::bind(sfd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr));
::umask(old_umask);
if (bind_rc < 0) {
BOOST_LOG_TRIVIAL(error) << "MCP: bind() failed on " << sock_path;
::close(sfd); return;
}
if (::chmod(sock_path.c_str(), S_IRUSR | S_IWUSR) < 0) {
BOOST_LOG_TRIVIAL(error) << "MCP: cannot restrict " << sock_path << " to the owner; refusing to listen";
::close(sfd); ::unlink(sock_path.c_str()); return;
}
if (::listen(sfd, 1) < 0) { BOOST_LOG_TRIVIAL(error) << "MCP: listen() failed"; ::close(sfd); return; }
g_sock_path = sock_path;
std::atexit(remove_socket_at_exit); // do not leave the socket file behind
BOOST_LOG_TRIVIAL(info) << "MCP control listening on " << sock_path;
for (;;) {
int cfd = ::accept(sfd, nullptr, nullptr);
if (cfd < 0) continue;
#if !defined(MSG_NOSIGNAL) && defined(SO_NOSIGPIPE)
const int on = 1; // macOS/BSD equivalent of MSG_NOSIGNAL
::setsockopt(cfd, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on));
#endif
serve_client(cfd);
::close(cfd);
}
}
} // namespace
void start_mcp_control_if_enabled()
{
const char* env = std::getenv("ORCA_CAD_MCP");
if (!env || !*env) return;
std::string path = (std::strcmp(env, "1") == 0) ? "/tmp/orca-cad-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