Files
FreeCADWasmDev/freecad-source/src/WebAssembly/FreeCADCoreProbe/CAMDesktopCommands.h
wangdequan 9a7c4f0335 Save FreeCAD Web workspace sources and user validation evidence
IFC native binding remains incomplete; preserve accurate test results.
2026-09-15 22:01:58 +08:00

451 lines
25 KiB
C++

// SPDX-License-Identifier: LGPL-2.1-or-later
#pragma once
#include <App/Application.h>
#include <App/Document.h>
#include <App/PropertyStandard.h>
#include <Mod/CAM/App/FeaturePath.h>
#include <QJsonArray>
#include <QJsonObject>
#include <QJsonDocument>
#include <QFileInfo>
#include <QSaveFile>
#include <Python.h>
#include <set>
#include <stdexcept>
#include <vector>
namespace CAMWeb {
bool dispatchRegisteredDesktop(const QString& command, const QJsonObject& args, QJsonObject& result);
inline void appendDocumentFile(const QJsonObject& args, QJsonObject& nativeResult)
{
if (!args.value("saveDocument").toBool() || nativeResult.value("cancelled").toBool())
return;
const auto gil = PyGILState_Ensure();
PyObject* scope = PyDict_New();
PyObject* request = PyUnicode_FromString(QJsonDocument(args).toJson(QJsonDocument::Compact).constData());
PyObject* output = nullptr;
if (scope && request && PyDict_SetItemString(scope, "request_json", request) == 0
&& PyDict_SetItemString(scope, "__builtins__", PyEval_GetBuiltins()) == 0)
output = PyRun_String(R"CAM(
import json, base64, os, tempfile, FreeCAD
args = json.loads(request_json)
doc = FreeCAD.getDocument(args['documentId'])
with tempfile.TemporaryDirectory() as folder:
filename = doc.Name + '.FCStd'
path = os.path.join(folder, filename)
doc.saveCopy(path)
with open(path, 'rb') as saved:
content = saved.read()
file_json = json.dumps({'fileName': filename, 'contentBase64': base64.b64encode(content).decode('ascii'), 'byteLength': len(content)})
)CAM", Py_file_input, scope, scope);
PyObject* value = output ? PyDict_GetItemString(scope, "file_json") : nullptr;
if (value && PyUnicode_Check(value)) {
auto files = nativeResult.value("files").toArray();
files.append(QJsonDocument::fromJson(QByteArray(PyUnicode_AsUTF8(value))).object());
nativeResult.insert("files", files);
} else {
PyObject *type = nullptr, *exception = nullptr, *traceback = nullptr;
PyErr_Fetch(&type, &exception, &traceback);
PyErr_NormalizeException(&type, &exception, &traceback);
PyObject* message = exception ? PyObject_Str(exception) : nullptr;
const char* detail = message ? PyUnicode_AsUTF8(message) : nullptr;
// The operation has committed; retain its result even when serialization fails.
nativeResult.insert("persistenceError", detail ? QString::fromUtf8(detail)
: QStringLiteral("CAM document serialization failed"));
Py_XDECREF(message); Py_XDECREF(type); Py_XDECREF(exception); Py_XDECREF(traceback);
PyErr_Clear();
}
Py_XDECREF(output); Py_XDECREF(request); Py_XDECREF(scope);
PyGILState_Release(gil);
}
inline void saveToolBitAs(const QJsonObject& args, QJsonObject& result)
{
QString code = QStringLiteral("invalid_request");
try {
const QString documentId = args.value(QStringLiteral("documentId")).toString();
const auto selection = args.value(QStringLiteral("selection")).toArray();
if (documentId.isEmpty() || selection.size() != 1 || !selection[0].isObject())
throw std::runtime_error("Select one document toolbit to save");
const auto selected = selection[0].toObject();
if (selected.contains(QStringLiteral("documentId"))
&& selected.value(QStringLiteral("documentId")).toString() != documentId)
throw std::runtime_error("The toolbit must belong to the selected document");
const QString path = args.contains(QStringLiteral("path"))
? args.value(QStringLiteral("path")).toString() : args.value(QStringLiteral("file")).toString();
if (path.trimmed().isEmpty() || path.contains(QChar(0)))
throw std::runtime_error("ToolBit Save As requires a file path");
if (args.value(QStringLiteral("action")).toString() == QStringLiteral("cancel")) {
result.insert(QStringLiteral("status"), QStringLiteral("exposed"));
result.insert(QStringLiteral("result"), QJsonObject{{QStringLiteral("cancelled"), true}});
return;
}
code = QStringLiteral("overwrite_required");
if (QFileInfo::exists(path) && !args.value(QStringLiteral("overwrite")).toBool())
throw std::runtime_error("Confirm overwrite of the existing toolbit file");
code = QStringLiteral("toolbit_serialization_failed");
QByteArray bytes;
// Run desktop serializers against the live proxy, with request data in a private scope.
const auto gil = PyGILState_Ensure();
PyObject* scope = PyDict_New();
PyObject* request = PyUnicode_FromString(QJsonDocument(args).toJson(QJsonDocument::Compact).constData());
PyObject* output = nullptr;
if (scope && request && PyDict_SetItemString(scope, "request_json", request) == 0
&& PyDict_SetItemString(scope, "__builtins__", PyEval_GetBuiltins()) == 0) {
output = PyRun_String(
"import json, pathlib, FreeCAD\n"
"from Path.Tool.toolbit import ToolBit\n"
"from Path.Tool.toolbit.serializers import all_serializers\n"
"args = json.loads(request_json)\n"
"doc = FreeCAD.getDocument(args['documentId'])\n"
"obj = doc.getObject(args['selection'][0]['object'])\n"
"if obj is None or not isinstance(getattr(obj, 'Proxy', None), ToolBit):\n"
" raise ValueError('Select a document ToolBit object')\n"
"extension = pathlib.Path(args.get('path', args.get('file'))).suffix.lower()\n"
"serializer = next((s for s in all_serializers if extension in s.extensions), None)\n"
"if serializer is None:\n"
" raise ValueError('No toolbit serializer for extension ' + extension)\n"
"content = serializer.serialize(obj.Proxy)\n",
Py_file_input, scope, scope);
}
QString error;
PyObject* content = output ? PyDict_GetItemString(scope, "content") : nullptr;
if (content && PyBytes_Check(content)) {
bytes = QByteArray(PyBytes_AS_STRING(content), PyBytes_GET_SIZE(content));
} else {
PyObject *type = nullptr, *value = nullptr, *traceback = nullptr;
PyErr_Fetch(&type, &value, &traceback);
PyErr_NormalizeException(&type, &value, &traceback);
PyObject* message = value ? PyObject_Str(value) : nullptr;
const char* text = message ? PyUnicode_AsUTF8(message) : nullptr;
error = text ? QString::fromUtf8(text) : QStringLiteral("Toolbit serializer did not return bytes");
Py_XDECREF(message); Py_XDECREF(type); Py_XDECREF(value); Py_XDECREF(traceback);
PyErr_Clear();
}
Py_XDECREF(output); Py_XDECREF(request); Py_XDECREF(scope);
PyGILState_Release(gil);
if (!error.isEmpty()) throw std::runtime_error(error.toStdString());
code = QStringLiteral("file_write_failed");
QSaveFile file(path);
if (!file.open(QIODevice::WriteOnly) || file.write(bytes) != bytes.size() || !file.commit())
throw std::runtime_error(file.errorString().toStdString());
result.insert(QStringLiteral("status"), QStringLiteral("exposed"));
result.insert(QStringLiteral("result"), QJsonObject{
{QStringLiteral("documentId"), documentId},
{QStringLiteral("object"), selected.value(QStringLiteral("object"))},
{QStringLiteral("fileName"), QFileInfo(path).fileName()},
{QStringLiteral("path"), path},
{QStringLiteral("contentBase64"), QString::fromLatin1(bytes.toBase64())},
{QStringLiteral("byteLength"), bytes.size()}});
} catch (const std::exception& error) {
result.insert(QStringLiteral("status"), QStringLiteral("restricted"));
result.insert(QStringLiteral("code"), code);
result.insert(QStringLiteral("message"), QString::fromUtf8(error.what()));
}
}
// Kept inline so the existing CAM runtime target owns this handler and its document.
inline void toggleOperations(const QJsonObject& args, QJsonObject& result)
{
if (args.value("action").toString() == "cancel") {
result.insert("status", "exposed");
result.insert("result", QJsonObject{{"cancelled", true}});
return;
}
App::Document* document = nullptr;
bool transaction = false;
QString code = QStringLiteral("invalid_request");
try {
const QString documentId = args.value(QStringLiteral("documentId")).toString();
if (documentId.isEmpty())
throw std::runtime_error("CAM_OpActiveToggle requires documentId");
if (args.value(QStringLiteral("activeTask")).toBool())
throw std::runtime_error("Finish the active task before toggling CAM operations");
code = QStringLiteral("document_not_found");
document = App::GetApplication().getDocument(documentId.toUtf8().constData());
if (!document)
throw std::runtime_error("The selected CAM document does not exist");
code = QStringLiteral("selection_required");
const QJsonArray selection = args.value(QStringLiteral("selection")).toArray();
if (selection.isEmpty())
throw std::runtime_error("Select CAM operations to toggle");
std::vector<Path::Feature*> operations;
std::set<App::DocumentObject*> seen;
code = QStringLiteral("invalid_selection");
for (const auto& entry : selection) {
if (!entry.isObject())
throw std::runtime_error("CAM operation selection must contain document objects");
const QJsonObject selected = entry.toObject();
if (selected.contains(QStringLiteral("documentId"))
&& selected.value(QStringLiteral("documentId")).toString() != documentId)
throw std::runtime_error("CAM operations must belong to the selected document");
const QString name = selected.value(QStringLiteral("object")).toString();
auto* operation = name.isEmpty() ? nullptr
: dynamic_cast<Path::Feature*>(document->getObject(name.toUtf8().constData()));
if (!operation || !dynamic_cast<App::PropertyBool*>(operation->getPropertyByName("Active")))
throw std::runtime_error("Select a CAM operation with an Active property");
if (seen.insert(operation).second)
operations.push_back(operation);
}
document->openTransaction("Toggle CAM operation active state");
transaction = true;
for (auto* operation : operations) {
auto* active = static_cast<App::PropertyBool*>(operation->getPropertyByName("Active"));
active->setValue(!active->getValue());
}
code = QStringLiteral("recompute_failed");
bool hasError = false;
if (document->recompute({}, false, &hasError) < 0 || hasError)
throw std::runtime_error("CAM operation recompute failed");
QJsonArray updated;
for (auto* operation : operations) {
if (operation->isError())
throw std::runtime_error("A selected CAM operation has an execution error");
updated.append(QJsonObject{
{QStringLiteral("object"), QString::fromUtf8(operation->getNameInDocument())},
{QStringLiteral("active"), static_cast<App::PropertyBool*>(operation->getPropertyByName("Active"))->getValue()}});
}
document->commitTransaction();
transaction = false;
result.insert(QStringLiteral("status"), QStringLiteral("exposed"));
result.insert(QStringLiteral("code"), QStringLiteral("native_operation_active_toggle"));
QJsonObject nativeResult{
{QStringLiteral("documentId"), documentId},
{QStringLiteral("keptOpen"), true},
{QStringLiteral("operations"), updated},
{QStringLiteral("selection"), selection},
{QStringLiteral("recomputed"), true}};
appendDocumentFile(args, nativeResult);
result.insert(QStringLiteral("result"), nativeResult);
}
catch (const std::exception& error) {
if (transaction && document)
document->abortTransaction();
result.insert(QStringLiteral("status"), QStringLiteral("restricted"));
result.insert(QStringLiteral("code"), code);
result.insert(QStringLiteral("message"), QString::fromUtf8(error.what()));
}
}
inline void toolBitCommand(const QString& command, const QJsonObject& args, QJsonObject& result)
{
try {
const QString docId = args.value("documentId").toString();
if (docId.isEmpty()) throw std::runtime_error("ToolBit command requires documentId");
const QString path = args.value("path").toString(args.value("file").toString());
if (command == "CAM_ToolBitDock" || command == "CAM_ToolBitLibraryOpen") {
result.insert("status", "exposed"); result.insert("result", QJsonObject{{"opened", true}, {"command", command}}); return;
}
if (path.isEmpty()) throw std::runtime_error("ToolBit file path is required");
if (command == "CAM_ToolBitLoad") {
const auto gil = PyGILState_Ensure();
PyObject* scope = PyDict_New(); PyObject* req = PyUnicode_FromString(QJsonDocument(args).toJson(QJsonDocument::Compact).constData());
PyObject* out = nullptr;
if (scope && req && PyDict_SetItemString(scope, "request_json", req) == 0)
out = PyRun_String("import json, FreeCAD\nfrom Path.Tool.toolbit import ToolBit\nfrom Path.Tool.toolbit.serializers import all_serializers\na=json.loads(request_json)\nd=FreeCAD.getDocument(a['documentId'])\next=__import__('pathlib').Path(a['path']).suffix.lower()\ns=next((x for x in all_serializers if ext in x.extensions), None)\nif s is None: raise ValueError('No toolbit serializer for extension '+ext)\nt=s.deserialize(a['path'])\nt.attach_to_doc(d)\nd.recompute()\n", Py_file_input, scope, scope);
if (!out) { PyErr_Clear(); Py_XDECREF(req); Py_XDECREF(scope); PyGILState_Release(gil); throw std::runtime_error("ToolBit load failed"); }
Py_DECREF(out); Py_XDECREF(req); Py_XDECREF(scope); PyGILState_Release(gil);
result.insert("status", "exposed"); result.insert("result", QJsonObject{{"documentId", docId}, {"path", path}, {"loaded", true}}); return;
}
const auto sel = args.value("selection").toArray(); if (sel.size()!=1) throw std::runtime_error("Select one ToolBit");
if (QFileInfo::exists(path) && !args.value("overwrite").toBool()) throw std::runtime_error("Confirm overwrite of the existing toolbit file");
QJsonObject saveArgs(args); saveArgs.insert("path", path); saveToolBitAs(saveArgs, result);
} catch (const std::exception& e) { result.insert("status", "restricted"); result.insert("code", "toolbit_command_failed"); result.insert("message", QString::fromUtf8(e.what())); }
}
inline void executeDesktopCommand(const QString& command, const QJsonObject& args, QJsonObject& result)
{
try {
const QString documentId = args.value("documentId").toString();
if (documentId.isEmpty()) throw std::runtime_error("CAM command requires documentId");
App::Document* document = App::GetApplication().getDocument(documentId.toUtf8().constData());
if (!document) throw std::runtime_error("The selected CAM document does not exist");
QJsonObject requestArgs(args);
requestArgs.insert("command", command);
const auto gil = PyGILState_Ensure();
PyObject* scope = PyDict_New();
PyObject* request = PyUnicode_FromString(QJsonDocument(requestArgs).toJson(QJsonDocument::Compact).constData());
PyObject* output = nullptr;
if (scope && request && PyDict_SetItemString(scope, "request_json", request) == 0
&& PyDict_SetItemString(scope, "__builtins__", PyEval_GetBuiltins()) == 0)
output = PyRun_String(R"CAM(
import json, importlib, base64, os
import FreeCAD
from PathScripts import PathUtils
a = json.loads(request_json)
d = FreeCAD.getDocument(a['documentId'])
command = a['command']
result = {'documentId': d.Name, 'keptOpen': True}
def configure_operation(obj, job):
if a.get('toolController'):
tool = d.getObject(a['toolController'])
if tool not in job.Tools.Group:
raise ValueError('Choose a tool controller from the job')
obj.ToolController = tool
for key, value in a.get('parameters', {}).items():
if key not in obj.PropertiesList or key in ('Base', 'ToolController', 'Path', 'ExpressionEngine', 'Proxy'):
raise ValueError('Unsupported operation parameter: ' + key)
if any(path == key for path, expression in obj.ExpressionEngine):
obj.setExpression(key, None)
setattr(obj, key, value)
def recompute_operations(operations):
d.recompute()
if any('Invalid' in obj.State or 'Error' in obj.State for obj in operations):
raise RuntimeError('CAM operation recompute failed')
if any(getattr(obj, 'Active', True) and not obj.Path.Commands for obj in operations):
raise RuntimeError('CAM operation produced no toolpath; check geometry, tool and operation parameters')
def run():
if a.get('action') == 'cancel':
result['cancelled'] = True
return
if a.get('activeTask'):
raise ValueError('Finish the active task first')
selection = a.get('selection', [])
objects = []
for item in selection:
if item.get('documentId', d.Name) != d.Name:
raise ValueError('Selection belongs to another document')
obj = d.getObject(item['object'])
if obj is None:
raise ValueError('Selected object does not exist: ' + item['object'])
objects.append(obj)
if command == 'CAM_SelectLoop':
expanded = []
for item, obj in zip(selection, objects):
shape = obj.Shape
names = item.get('subelements', item.get('subElementNames', []))
if not names:
raise ValueError('Select a face or an edge for loop selection')
edges = []
for name in names:
seed = shape.getElement(name)
if seed.ShapeType == 'Face':
wires = seed.Wires
elif seed.ShapeType == 'Edge':
wires = [w for w in shape.Wires if any(e.isSame(seed) for e in w.Edges)]
else:
raise ValueError('Loop selection requires faces or edges')
for wire in wires:
for edge in wire.Edges:
for index, candidate in enumerate(shape.Edges, 1):
key = 'Edge' + str(index)
if candidate.isSame(edge) and key not in edges:
edges.append(key)
if not edges:
raise ValueError('No connected wire found for selection')
expanded.append({'documentId': d.Name, 'object': obj.Name, 'subelements': edges})
if not expanded:
raise ValueError('Select a face or edge')
result['selection'] = expanded
return
job = d.getObject(a['job']) if a.get('job') else None
if job is None:
candidates = []
for obj in objects:
parent = obj if hasattr(obj, 'Operations') else PathUtils.findParentJob(obj)
if parent and parent not in candidates:
candidates.append(parent)
if not candidates:
candidates = [obj for obj in d.Objects if hasattr(obj, 'Operations') and hasattr(obj, 'Model')]
if len(candidates) != 1:
raise ValueError('Choose one CAM job')
job = candidates[0]
if not hasattr(job, 'Operations'):
raise ValueError('Choose a CAM job')
changed = []
d.openTransaction(command)
try:
if command in ('CAM_Post', 'CAM_PostSelected'):
from Path.Post.Processor import PostProcessorFactory
selected = command == 'CAM_PostSelected'
if selected and (not objects or any(obj not in job.Operations.Group for obj in objects)):
raise ValueError('Select operations from the chosen job')
recompute_operations(objects if selected else job.Operations.Group)
if 'postProcessorArgs' in a:
job.PostProcessorArgs = a['postProcessorArgs']
machine = getattr(job, 'Machine', None)
processor = a.get('postProcessor') or job.PostProcessor
if machine and not selected:
from Machine.models.machine import MachineFactory
processor = MachineFactory.get_machine(machine).postprocessor_file_name
if not processor:
raise ValueError('Choose a post processor')
post = PostProcessorFactory.get_post_processor({'job': job, 'operations': objects} if selected else job, processor)
data = post.export2() if machine and not selected else post.export()
if not data:
raise ValueError('Post processor produced no output')
result['files'] = []
requested = os.path.basename(a.get('fileName') or (job.Name + '.nc'))
stem, extension = os.path.splitext(requested)
for index, (_, gcode) in enumerate(data):
content = gcode.encode('utf-8') if isinstance(gcode, str) else bytes(gcode)
filename = requested if len(data) == 1 else stem + '-' + str(index + 1) + extension
result['files'].append({'fileName': filename, 'contentBase64': base64.b64encode(content).decode('ascii'), 'byteLength': len(content)})
elif command == 'CAM_OperationCopy':
if not objects or any(obj not in job.Operations.Group for obj in objects):
raise ValueError('Select operations from the chosen job')
for source in dict.fromkeys(objects):
obj = d.copyObject(source, False)
job.Proxy.addOperation(obj)
configure_operation(obj, job)
obj.touch()
changed.append(obj)
else:
modules = {'CAM_Pocket3D': 'Pocket', 'CAM_Pocket_Shape': 'PocketShape', 'CAM_Probe': 'Probe', 'CAM_Profile': 'Profile'}
module = importlib.import_module('Path.Op.' + modules[command])
obj = module.Create(a.get('name') or modules[command], parentJob=job)
if objects and command != 'CAM_Probe':
obj.Base = [(o, s.get('subelements', s.get('subElementNames', []))) for o, s in zip(objects, selection) if o != job]
configure_operation(obj, job)
changed.append(obj)
if changed:
recompute_operations(changed)
result['operations'] = [{'object': obj.Name, 'active': getattr(obj, 'Active', True), 'gcode': obj.Path.toGCode(), 'commandCount': len(obj.Path.Commands)} for obj in changed]
if changed:
result['selection'] = [{'documentId': d.Name, 'object': obj.Name} for obj in changed]
result['job'] = job.Name
result['recomputed'] = True
d.commitTransaction()
except Exception:
d.abortTransaction()
raise
previous_document = FreeCAD.ActiveDocument
try:
FreeCAD.setActiveDocument(d.Name)
run()
finally:
FreeCAD.setActiveDocument(previous_document.Name if previous_document else '')
result_json = json.dumps(result)
)CAM", Py_file_input, scope, scope);
QString error;
QJsonObject nativeResult;
PyObject* value = output ? PyDict_GetItemString(scope, "result_json") : nullptr;
if (value && PyUnicode_Check(value)) {
nativeResult = QJsonDocument::fromJson(QByteArray(PyUnicode_AsUTF8(value))).object();
} else {
PyObject *type = nullptr, *exception = nullptr, *traceback = nullptr;
PyErr_Fetch(&type, &exception, &traceback);
PyErr_NormalizeException(&type, &exception, &traceback);
PyObject* message = exception ? PyObject_Str(exception) : nullptr;
const char* detail = message ? PyUnicode_AsUTF8(message) : nullptr;
error = detail ? QString::fromUtf8(detail) : QStringLiteral("CAM command returned no result");
Py_XDECREF(message); Py_XDECREF(type); Py_XDECREF(exception); Py_XDECREF(traceback);
PyErr_Clear();
}
Py_XDECREF(output); Py_XDECREF(request); Py_XDECREF(scope);
PyGILState_Release(gil);
if (!error.isEmpty()) throw std::runtime_error(error.toStdString());
appendDocumentFile(args, nativeResult);
result.insert("status", "exposed");
result.insert("code", "native_cam_desktop_workflow");
result.insert("result", nativeResult);
} catch (const std::exception& e) { result.insert("status", "restricted"); result.insert("code", "cam_command_failed"); result.insert("message", QString::fromUtf8(e.what())); }
}
} // namespace CAMWeb