// SPDX-License-Identifier: LGPL-2.1-or-later #pragma once #include #include #include #include #include #include #include #include #include #include #include #include #include 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 operations; std::set 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(document->getObject(name.toUtf8().constData())); if (!operation || !dynamic_cast(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(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(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