mirror of
https://github.com/OrcaSlicer/OrcaSlicer.git
synced 2026-10-13 19:01:21 +00:00
A sketch drawn on the rig extruded to walls with no caps. Every joint of its loop met, so the loop analysis called it closed and MakeFace accepted it, but the loop crossed itself: an arc left the top line's end heading back over it and crossed it again 2.5 mm on. A second arc left a 0.28 mm line tangent to it but the other way, a cusp. The prism of that face is an invalid solid, and it was shipped as a body. - SketchEngine::wires_to_face checks the face it builds and, when OCCT calls it invalid, fails with "the profile crosses or folds back on itself, so it does not bound one region". The extrude reports that instead of producing the broken body. - sketch_loop_defect() judges a closed loop of lines and arcs exactly: any contact between two of its entities away from the joints they share, or a joint where the curve turns straight back (a cusp; OCCT still builds that one, but it is never what was meant). It returns the point. - The sketch uses it on every region: the loop is tinted red, the point gets a marker, and the status line says what the red means the first time one appears. The MCP loop report lists the defects and no longer calls such a profile buildable. Tests: the rig's profile, with each defect and with both, from a recording of the real entities. The analysis names the cusp at its joint and the crossing on the top line, in either traversal order, and passes ordinary tangent and collinear joints. The extrude refuses every crossing variant with the reason, and the same arcs swept the other way round extrude to a valid solid. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QK4VgguuCAk2hZLWgcjJb9
2298 lines
128 KiB
C++
2298 lines
128 KiB
C++
#include "slic3r/GUI/CAD/McpControl.hpp"
|
|
|
|
#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 (not a std::exception)
|
|
|
|
#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), Standard_False);
|
|
sect.ComputePCurveOn1(Standard_False);
|
|
sect.Approximation(Standard_True);
|
|
sect.Build();
|
|
if (!sect.IsDone()) throw std::runtime_error("section failed");
|
|
std::vector<std::vector<Vec3d>> segs;
|
|
for (TopExp_Explorer ex(sect.Shape(), TopAbs_EDGE); ex.More(); ex.Next()) {
|
|
std::vector<Vec3d> pts = GeometryEngine::sample_edge_world(TopoDS::Edge(ex.Current()));
|
|
if (pts.size() >= 2) segs.push_back(std::move(pts));
|
|
}
|
|
const int raw = int(segs.size());
|
|
auto contours = chain_segments(std::move(segs), 1e-3);
|
|
json jcont = json::array();
|
|
int closed_n = 0;
|
|
for (auto& pc : contours) {
|
|
if (pc.second) ++closed_n;
|
|
json pts = json::array();
|
|
for (const Vec3d& p : pc.first) pts.push_back(vec3(p));
|
|
jcont.push_back(json{{"closed", pc.second}, {"points", std::move(pts)}});
|
|
}
|
|
return json{{"body", params.value("body", 0)}, {"plane", plane_name}, {"offset", offset},
|
|
{"segment_count", raw}, {"contour_count", int(contours.size())},
|
|
{"closed_count", closed_n}, {"contours", std::move(jcont)}};
|
|
}
|
|
|
|
// --- Build: bring a reference part in (Import STEP as native B-rep bodies) ------
|
|
json import_step(DesignPanel* panel, const json& params)
|
|
{
|
|
if (!params.contains("path")) throw std::runtime_error("import_step needs 'path'");
|
|
const std::string path = params["path"].get<std::string>();
|
|
std::string err;
|
|
std::vector<TopoDS_Shape> solids = GeometryEngine::read_step_solids(path, err);
|
|
if (solids.empty()) throw std::runtime_error(err.empty() ? "no solids in STEP" : err);
|
|
|
|
CadDocument& doc = panel->mcp_doc();
|
|
doc.checkpoint();
|
|
int first = int(doc.features.size());
|
|
for (const TopoDS_Shape& s : solids) {
|
|
CadFeature f;
|
|
f.type = CadFeatureType::Import;
|
|
f.name = "STEP" + std::to_string(int(doc.features.size()) + 1);
|
|
f.imported_solid = s;
|
|
f.mode = BooleanMode::New; // each solid = its own coexisting body
|
|
doc.features.push_back(f);
|
|
}
|
|
bool ok = doc.recompute();
|
|
if (!ok) { 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);
|
|
Standard_Real x0, y0, z0, x1, y1, z1; bb.Get(x0, y0, z0, x1, y1, z1);
|
|
return {vp.Mass(), Vec3d(c.X(), c.Y(), c.Z()), Vec3d(x0, y0, z0), Vec3d(x1, y1, z1)};
|
|
}
|
|
|
|
json validate_against(DesignPanel* panel, const json& params)
|
|
{
|
|
const TopoDS_Shape& cand = body_shape(panel, params); // candidate = the reconstruction
|
|
if (!params.contains("reference")) throw std::runtime_error("validate_against needs 'reference' {step|body}");
|
|
const json& r = params["reference"];
|
|
|
|
TopoDS_Shape ref;
|
|
if (r.contains("step")) {
|
|
std::string err;
|
|
std::vector<TopoDS_Shape> solids = GeometryEngine::read_step_solids(r["step"].get<std::string>(), err);
|
|
if (solids.empty()) throw std::runtime_error(err.empty() ? "reference STEP has no solids" : err);
|
|
BRep_Builder b; TopoDS_Compound comp; b.MakeCompound(comp);
|
|
for (const TopoDS_Shape& s : solids) if (!s.IsNull()) b.Add(comp, s);
|
|
ref = comp;
|
|
} else if (r.contains("body")) {
|
|
CadDocument& doc = panel->mcp_doc();
|
|
int idx = r["body"].get<int>();
|
|
if (idx < 0 || idx >= int(doc.bodies.size()) || doc.bodies[idx].shape.IsNull())
|
|
throw std::runtime_error("reference body index out of range / null");
|
|
ref = doc.bodies[idx].shape;
|
|
} else {
|
|
throw std::runtime_error("reference must be {\"step\": path} or {\"body\": id}");
|
|
}
|
|
|
|
ShapeMetrics a = shape_metrics(cand), b = shape_metrics(ref);
|
|
double dv = b.volume > 0 ? (a.volume - b.volume) / b.volume * 100.0 : 0.0;
|
|
Vec3d coff = a.centroid - b.centroid;
|
|
Vec3d dmin = a.bmin - b.bmin, dmax = a.bmax - b.bmax;
|
|
// Surface-level deviation (one-sided Hausdorff, candidate vertices -> reference solid):
|
|
// catches local shape error that matching volume + bbox can hide.
|
|
GeometryEngine::Deviation dev = GeometryEngine::surface_deviation(cand, ref);
|
|
return json{
|
|
{"volume", a.volume}, {"volume_reference", b.volume}, {"volume_delta_pct", dv},
|
|
{"centroid_offset", vec3(coff)}, {"centroid_offset_mm", coff.norm()},
|
|
{"bbox", json{{"min", vec3(a.bmin)}, {"max", vec3(a.bmax)}}},
|
|
{"bbox_reference", json{{"min", vec3(b.bmin)}, {"max", vec3(b.bmax)}}},
|
|
{"bbox_delta", json{{"min", vec3(dmin)}, {"max", vec3(dmax)}}},
|
|
{"surface_deviation", json{{"max_mm", dev.max_mm}, {"mean_mm", dev.mean_mm},
|
|
{"rms_mm", dev.rms_mm}, {"samples", dev.sample_count}}},
|
|
};
|
|
}
|
|
|
|
json action_extrude(DesignPanel* panel, const json& params)
|
|
{
|
|
const double d = params.value("distance", 10.0);
|
|
if (d <= 0) throw std::runtime_error("distance must be > 0");
|
|
CadDocument& doc = panel->mcp_doc();
|
|
SketchPlane pl = plane_from(params, doc);
|
|
BooleanMode mode = bool_from(params.value("boolean", std::string("new")));
|
|
|
|
SketchProfile prof;
|
|
bool has_prof = profile_from(params, prof);
|
|
double w = 0, h = 0;
|
|
if (!has_prof) {
|
|
w = params.value("width", 20.0); h = params.value("height", 20.0);
|
|
if (w <= 0 || h <= 0) throw std::runtime_error("width and height must be > 0");
|
|
}
|
|
doc.checkpoint();
|
|
int s = has_prof ? doc.add_sketch_profile(prof, pl, "Sketch")
|
|
: doc.add_sketch(SketchShape::Rectangle, pl, w, h, 0.0, "Sketch");
|
|
int e = doc.add_extrude(s, d, /*symmetric*/false, mode, "Extrude");
|
|
// End condition (Onshape parity). apply reads extrude_end directly; the kernel `symmetric`
|
|
// bool is unused, so set the field here. up_to_face id comes from query_topology on the
|
|
// target (last) body. taper_deg lofts the side wall; flip negates the direction.
|
|
const std::string end = params.value("end", std::string("blind"));
|
|
CadFeature& fe = doc.features[e];
|
|
fe.flip = params.value("flip", false);
|
|
fe.taper_deg = params.value("taper", 0.0);
|
|
if (end == "symmetric") fe.extrude_end = ExtrudeEnd::Symmetric;
|
|
else if (end == "two_sided") { fe.extrude_end = ExtrudeEnd::TwoSided; fe.distance2 = params.value("distance2", d); }
|
|
else if (end == "through_all") fe.extrude_end = ExtrudeEnd::ThroughAll;
|
|
else if (end == "up_to_face") { fe.extrude_end = ExtrudeEnd::UpToFace; fe.up_to_face = params.value("up_to_face", -1); }
|
|
else fe.extrude_end = ExtrudeEnd::Blind;
|
|
bool ok = doc.recompute();
|
|
if (!ok) { 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) { // OCCT errors are NOT std::exception
|
|
return rpc_error(id, -32000, std::string("OCCT: ") + (ex.GetMessageString() ? ex.GetMessageString() : "failure"));
|
|
} catch (const std::exception& ex) {
|
|
return rpc_error(id, -32000, ex.what());
|
|
}
|
|
}
|
|
|
|
// Marshal a request to the main thread and block (with a timeout) for the reply.
|
|
std::string dispatch_request(const std::string& line)
|
|
{
|
|
json req;
|
|
try { req = json::parse(line); }
|
|
catch (const std::exception& ex) { return rpc_error(nullptr, -32700, std::string("parse error: ") + ex.what()); }
|
|
|
|
json id = req.contains("id") ? req["id"] : json(nullptr);
|
|
std::string method = req.value("method", std::string());
|
|
json params = req.contains("params") ? req["params"] : json::object();
|
|
if (method.empty()) return rpc_error(id, -32600, "missing method");
|
|
|
|
auto prom = std::make_shared<std::promise<std::string>>();
|
|
auto fut = prom->get_future();
|
|
// 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
|