From 77925a996d63888cb46b239b6f6998602db7d927 Mon Sep 17 00:00:00 2001 From: wangdequan Date: Tue, 8 Sep 2026 15:05:38 +0800 Subject: [PATCH] Extend FreeCAD WebAssembly runtime support --- docs/business-acceptance/Part.json | 23 + src/App/CMakeLists.txt | 5 + src/Mod/Draft/draftutils/params.py | 8 + src/Mod/Draft/draftutils/utils.py | 4 +- src/Mod/Import/App/CMakeLists.txt | 7 +- src/Mod/PartDesign/WizardShaft/Shaft.py | 20 +- .../PartDesign/WizardShaft/ShaftFeature.py | 12 +- .../FreeCADCoreProbe/AssemblyRuntimeProbe.cpp | 252 +- .../FreeCADCoreProbe/CAMRuntimeProbe.cpp | 110 +- .../FreeCADCoreProbe/CMakeLists.txt | 21 +- .../FreeCADCoreProbe/DraftRuntimeProbe.cpp | 125 +- .../FreeCADCoreProbe/PartBoxProbe.cpp | 79 + .../PartDesignDesktopCommands.cpp | 230 ++ .../PartDesignDesktopCommands.h | 7 + .../PartDesignRuntimeProbe.cpp | 243 +- .../PartDesktopCommandProbe.cpp | 20 +- .../FreeCADCoreProbe/PartDesktopCommands.cpp | 821 ++++ .../SketcherDesktopCommands.cpp | 121 + .../SketcherDesktopCommands.h | 8 + .../FreeCADCoreProbe/SketcherProbe.cpp | 27 + .../TechDrawDesktopCommands.cpp | 3539 +++++++++++++++++ .../TechDrawDesktopCommands.h | 7 + .../FreeCADCoreProbe/TechDrawRuntimeProbe.cpp | 518 ++- 23 files changed, 6099 insertions(+), 108 deletions(-) create mode 100644 docs/business-acceptance/Part.json create mode 100644 src/WebAssembly/FreeCADCoreProbe/PartDesignDesktopCommands.cpp create mode 100644 src/WebAssembly/FreeCADCoreProbe/PartDesignDesktopCommands.h create mode 100644 src/WebAssembly/FreeCADCoreProbe/SketcherDesktopCommands.cpp create mode 100644 src/WebAssembly/FreeCADCoreProbe/SketcherDesktopCommands.h create mode 100644 src/WebAssembly/FreeCADCoreProbe/TechDrawDesktopCommands.cpp create mode 100644 src/WebAssembly/FreeCADCoreProbe/TechDrawDesktopCommands.h diff --git a/docs/business-acceptance/Part.json b/docs/business-acceptance/Part.json new file mode 100644 index 00000000..960bfeaf --- /dev/null +++ b/docs/business-acceptance/Part.json @@ -0,0 +1,23 @@ +{ + "module": "Part", + "target": "FreeCADWebPartBoxProbe", + "contract": "freecad-web-part/create-recompute/v1", + "executable": true, + "command": "part.createRecompute", + "input": { + "schema": "freecad-web-part/create-recompute/v1", + "module": "Part", + "action": "create/recompute", + "type": "Part::Box", + "dimensions": [2, 3, 4] + }, + "businessCase": { + "action": "create/recompute", + "type": "Part::Box", + "dimensions": [2, 3, 4], + "expected": { + "volume": 24, + "resourcesReleased": true + } + } +} diff --git a/src/App/CMakeLists.txt b/src/App/CMakeLists.txt index 7129e1ba..20efe9df 100644 --- a/src/App/CMakeLists.txt +++ b/src/App/CMakeLists.txt @@ -367,6 +367,11 @@ endif(FREECAD_USE_PCH) target_sources(FreeCADApp PRIVATE ${FreeCADApp_SRCS}) target_link_libraries(FreeCADApp ${FreeCADApp_LIBS}) +if(EMSCRIPTEN) + # PropertyListsT uses catch-and-fallback when Python assigns an object list. + target_compile_options(FreeCADApp PRIVATE "-fexceptions") +endif() + if (MSVC) target_compile_definitions(FreeCADApp PRIVATE WIN32_LEAN_AND_MEAN) target_compile_options(FreeCADApp PRIVATE /wd4251 /wd4273 /wd4275) diff --git a/src/Mod/Draft/draftutils/params.py b/src/Mod/Draft/draftutils/params.py index 02b42077..9be42cf7 100644 --- a/src/Mod/Draft/draftutils/params.py +++ b/src/Mod/Draft/draftutils/params.py @@ -701,6 +701,14 @@ def _get_param_dictionary(): if fd.open(QtCore.QIODevice.ReadOnly | QtCore.QFile.Text): text = QtCore.QTextStream(fd).readAll() fd.close() + elif not App.GuiUp: + # Headless runtimes embed the source UI files without Qt resources. + from pathlib import Path + + source = Path(__file__).resolve().parent.parent / "Resources" / "ui" / fnm.rsplit("/", 1)[-1] + if not source.is_file(): + continue + text = source.read_text(encoding="utf-8") else: continue diff --git a/src/Mod/Draft/draftutils/utils.py b/src/Mod/Draft/draftutils/utils.py index 44ddbe7e..ba2932e8 100644 --- a/src/Mod/Draft/draftutils/utils.py +++ b/src/Mod/Draft/draftutils/utils.py @@ -267,7 +267,9 @@ def svg_precision(): int params.get_param("svgPrecision") """ - return params.get_param("svgPrecision") + value = params.get_param("svgPrecision") + # A headless parameter database may not contain the SVG preference yet. + return 3 if value is None else value def tolerance(): diff --git a/src/Mod/Import/App/CMakeLists.txt b/src/Mod/Import/App/CMakeLists.txt index 4b56acc7..66c2aac7 100644 --- a/src/Mod/Import/App/CMakeLists.txt +++ b/src/Mod/Import/App/CMakeLists.txt @@ -88,7 +88,12 @@ target_include_directories( ${CMAKE_CURRENT_BINARY_DIR} ${CMAKE_CURRENT_SOURCE_DIR} ) -target_link_libraries(Import ${Import_LIBS}) +target_link_libraries(Import ${Import_LIBS}) + +if(EMSCRIPTEN) + # Keep native parser failures inside the Python error/transaction boundary. + target_compile_options(Import PRIVATE "-fexceptions") +endif() if (MSVC) target_compile_options(Import PRIVATE /wd4251 /wd4275) diff --git a/src/Mod/PartDesign/WizardShaft/Shaft.py b/src/Mod/PartDesign/WizardShaft/Shaft.py index fabc5638..a8174234 100644 --- a/src/Mod/PartDesign/WizardShaft/Shaft.py +++ b/src/Mod/PartDesign/WizardShaft/Shaft.py @@ -22,13 +22,23 @@ # * * # ******************************************************************************/ -import FreeCAD, FreeCADGui +import FreeCAD +if FreeCAD.GuiUp: + import FreeCADGui +else: + FreeCADGui = None from .SegmentFunction import SegmentFunction, IntervalFunction, StressFunction, TranslationFunction from .ShaftFeature import ShaftFeature -from .ShaftDiagram import Diagram import math +def Diagram(*args, **kwargs): + # Geometry creation does not require the desktop plotting backend. + from .ShaftDiagram import Diagram as ShaftDiagram + + return ShaftDiagram(*args, **kwargs) + + class ShaftSegment: def __init__(self, l, d, di): self.length = l @@ -113,12 +123,12 @@ class Shaft: if self.segments[i].constraintType != "Fixed": continue if i == len(self.segments) - 1: - self.segments[index].constraint.References = [ - (self.feature.feature, "Face%u" % (2 * (index + 1) + 1)) + self.segments[i].constraint.References = [ + (self.feature.feature, "Face%u" % (2 * (i + 1) + 1)) ] else: # Remove reference since it is now in the middle of the shaft (which is not allowed) - self.segments[index].constraint.References = [(None, "")] + self.segments[i].constraint.References = [(None, "")] def updateSegment(self, index, length=None, diameter=None, innerdiameter=None): oldLength = self.segments[index].length diff --git a/src/Mod/PartDesign/WizardShaft/ShaftFeature.py b/src/Mod/PartDesign/WizardShaft/ShaftFeature.py index 0c10f2be..cd95606b 100644 --- a/src/Mod/PartDesign/WizardShaft/ShaftFeature.py +++ b/src/Mod/PartDesign/WizardShaft/ShaftFeature.py @@ -22,7 +22,11 @@ # * * # ******************************************************************************/ -import FreeCAD, FreeCADGui +import FreeCAD +if FreeCAD.GuiUp: + import FreeCADGui +else: + FreeCADGui = None import Part, Sketcher @@ -148,11 +152,13 @@ class ShaftFeature: self.feature.ReferenceAxis = (self.sketch, ["H_Axis"]) self.feature.Angle = 360.0 self.Doc.recompute() - self.Gui.hide("SketchShaft") + if self.Gui is not None: + self.Gui.getDocument(self.Doc.Name).getObject(self.sketch.Name).hide() else: self.Doc.recompute() # FIXME: Will give a warning in the console if the active window is not the feature - self.Gui.SendMsgToActiveView("ViewFit") + if self.Gui is not None: + self.Gui.SendMsgToActiveView("ViewFit") def updateSegment(self, segment, oldLength, length, diameter, innerdiameter): constrRadius = 4 + segment * 6 diff --git a/src/WebAssembly/FreeCADCoreProbe/AssemblyRuntimeProbe.cpp b/src/WebAssembly/FreeCADCoreProbe/AssemblyRuntimeProbe.cpp index 48dc06e6..4e4d0433 100644 --- a/src/WebAssembly/FreeCADCoreProbe/AssemblyRuntimeProbe.cpp +++ b/src/WebAssembly/FreeCADCoreProbe/AssemblyRuntimeProbe.cpp @@ -488,35 +488,32 @@ EMSCRIPTEN_KEEPALIVE const char* fc_web_dispatch_json(const char* request) return dispatchResponse.c_str(); } + bool jointCommandsOnly = !records.isEmpty(); + for (const auto& value : records) { + const QString id = value.toObject().value(QStringLiteral("apiId")).toString(); + if (!id.startsWith(QStringLiteral("Assembly:desktop:"))) { + jointCommandsOnly = false; + break; + } + } ReadonlyFixture readonlyFixture; bool hasReadonlyFixture = false; - if (tier == QStringLiteral("tier-1-readonly")) { + if (tier == QStringLiteral("tier-1-readonly") && !jointCommandsOnly) { hasReadonlyFixture = true; readonlyFixture = runReadonlyFixture(); } ViewResourceFixture viewResourceFixture; bool hasViewResourceFixture = false; - if (tier == QStringLiteral("tier-1-readonly")) { + if (tier == QStringLiteral("tier-1-readonly") && !jointCommandsOnly) { hasViewResourceFixture = true; viewResourceFixture = runViewResourceFixture(); } DocumentGraphFixture documentGraphFixture; bool hasDocumentGraphFixture = false; - if (tier == QStringLiteral("object-contract")) { + if (tier == QStringLiteral("object-contract") && !jointCommandsOnly) { hasDocumentGraphFixture = true; documentGraphFixture = runDocumentGraphFixture(); } - bool hasDesktopFixture = false; - for (const auto& value : records) { - if (value.toObject().value(QStringLiteral("apiId")).toString().startsWith(QStringLiteral("Assembly:desktop:"))) { - hasDesktopFixture = true; - break; - } - } - DocumentGraphFixture desktopFixture; - if (hasDesktopFixture) { - desktopFixture = runDocumentGraphFixture(); - } QJsonArray results; for (const auto& value : records) { @@ -547,48 +544,199 @@ EMSCRIPTEN_KEEPALIVE const char* fc_web_dispatch_json(const char* request) result.insert(QStringLiteral("code"), QStringLiteral("invalid_record")); result.insert(QStringLiteral("message"), QStringLiteral("Assembly API record requires apiId, name and family")); } - // FC_WEB_AUDITED_DESKTOP_FAMILY("Assembly") - else if (apiId.startsWith(QStringLiteral("Assembly:desktop:"))) { - const QJsonObject commandArgs = input.value(QStringLiteral("args")).toObject(); - QString operation = QStringLiteral("select-assembly-items"); - QJsonArray selection = commandArgs.value(QStringLiteral("selection")).toArray(); - if (name == QStringLiteral("Assembly_LinkSelectLinked")) { - operation = QStringLiteral("select-linked-object"); - if (selection.isEmpty()) selection.append(QStringLiteral("Component")); + else if (apiId == QStringLiteral("Assembly:desktop:Assembly_CreateJointAngle") + || apiId == QStringLiteral("Assembly:desktop:Assembly_CreateJointBall") + || apiId == QStringLiteral("Assembly:desktop:Assembly_CreateJointBelt") + || apiId == QStringLiteral("Assembly:desktop:Assembly_CreateJointCylindrical")) { + QJsonObject jointArgs = input.value(QStringLiteral("args")).toObject(); + const int jointType = apiId.endsWith(QStringLiteral("Angle")) ? 8 + : apiId.endsWith(QStringLiteral("Ball")) ? 4 + : apiId.endsWith(QStringLiteral("Belt")) ? 12 : 2; + jointArgs.insert(QStringLiteral("jointTypeIndex"), jointType); + const QByteArray encoded = QJsonDocument(jointArgs).toJson(QJsonDocument::Compact); + const PyGILState_STATE gil = PyGILState_Ensure(); + PyObject* scope = PyDict_New(); + PyObject* payload = PyUnicode_DecodeUTF8(encoded.constData(), encoded.size(), "strict"); + PyObject* executed = nullptr; + if (scope && payload + && PyDict_SetItemString(scope, "__builtins__", PyEval_GetBuiltins()) == 0 + && PyDict_SetItemString(scope, "payload", payload) == 0) { + executed = PyRun_String(R"PY( +import json +import math +import FreeCAD as App +import JointObject +import UtilsAssembly + +def create_web_joint(args): + document_id = args.get('documentId') + assembly_name = args.get('assembly') + if not isinstance(document_id, str) or not document_id: + raise ValueError('documentId must identify an existing Assembly runtime document') + if not isinstance(assembly_name, str) or not assembly_name: + raise ValueError('assembly must name the active assembly object') + doc = App.getDocument(document_id) + assembly = doc.getObject(assembly_name) + if assembly is None or not assembly.isDerivedFrom('Assembly::AssemblyObject'): + raise ValueError('assembly must identify an Assembly::AssemblyObject in the document') + references = args.get('references') + if not isinstance(references, list) or len(references) != 2: + raise ValueError('references must contain two {object, subelements} entries') + refs = [] + for reference in references: + obj = doc.getObject(reference['object']) + subelements = reference.get('subelements') + if obj is None or not isinstance(subelements, list) or len(subelements) != 1: + raise ValueError('each reference requires an existing object and one subelement path') + if not isinstance(subelements[0], str): + raise ValueError('subelement path must be a string') + if obj != assembly and obj not in assembly.Group: + raise ValueError('reference object must belong to the requested assembly') + refs.append((obj, subelements)) + parts = [UtilsAssembly.getObject(ref) for ref in refs] + if any(part is None for part in parts) or parts[0] == parts[1]: + raise ValueError('select two elements from two separate parts') + properties = args.get('properties', {}) + if not isinstance(properties, dict): + raise ValueError('properties must be an object') + joint_type = JointObject.JointTypes[args['jointTypeIndex']] + allowed = set() + if joint_type in JointObject.JointUsingAngle: + allowed.add('Angle') + if joint_type in JointObject.JointUsingDistance: + allowed.add('Distance') + if joint_type in JointObject.JointUsingDistance2: + allowed.add('Distance2') + if joint_type in JointObject.JointUsingLimitLength: + allowed.update(('LengthMin', 'LengthMax')) + if joint_type in JointObject.JointUsingLimitAngle: + allowed.update(('AngleMin', 'AngleMax')) + for key, value in properties.items(): + if key not in allowed: + raise ValueError('parameter is unavailable for ' + joint_type + ': ' + key) + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + raise ValueError('joint parameters must be finite numbers in native property units') + if joint_type in JointObject.JointNoNegativeDistance and key in ('Distance', 'Distance2') and value < 0: + raise ValueError('joint distances must be non-negative') + for lower, upper in (('LengthMin', 'LengthMax'), ('AngleMin', 'AngleMax')): + if lower in properties and upper in properties and properties[lower] > properties[upper]: + raise ValueError(lower + ' must not exceed ' + upper) + doc.openTransaction('Create assembly joint') + try: + assembly.ensureIdentityPlacements() + group = UtilsAssembly.getJointGroup(assembly) + joint = group.newObject('App::FeaturePython', 'Joint') + JointObject.Joint(joint, args['jointTypeIndex']) + joint.Proxy.setJointConnectors(joint, refs) + for key, value in properties.items(): + if key not in joint.PropertiesList: + raise ValueError('parameter is unavailable for this joint: ' + key) + setattr(joint, key, value) + if 'label' in args: + if not isinstance(args['label'], str): + raise ValueError('label must be a string') + joint.Label = args['label'] + JointObject.solveIfAllowed(assembly) + assembly.recompute(True) + doc.recompute() + if any('Invalid' in obj.State for obj in (assembly, joint)): + raise RuntimeError('Assembly joint recompute failed') + values = {} + for key in properties: + value = getattr(joint, key) + values[key] = value.Value if hasattr(value, 'Value') else value + output = dict(documentId=doc.Name, assembly=assembly.Name, object=joint.Name, + label=joint.Label, jointType=str(joint.JointType), properties=values, + references=[dict(object=getattr(joint, key)[0].Name, + subelements=list(getattr(joint, key)[1])) + for key in ('Reference1', 'Reference2')]) + serialized = json.dumps(output, allow_nan=False) + doc.commitTransaction() + return serialized + except Exception: + doc.abortTransaction() + raise + +joint_result = create_web_joint(json.loads(payload)) +)PY", Py_file_input, scope, scope); } - else if (name == QStringLiteral("Assembly_SelectComponentsWithDoFs")) { - operation = QStringLiteral("select-components-with-dofs"); - selection = commandArgs.value(QStringLiteral("componentIds")).toArray(); - if (selection.isEmpty()) selection.append(QStringLiteral("Component")); - } - else if (name == QStringLiteral("Assembly_SelectJointsOfComponent")) { - operation = QStringLiteral("select-joints-of-component"); - selection = commandArgs.value(QStringLiteral("jointIds")).toArray(); - if (selection.isEmpty()) selection.append(QStringLiteral("JointGroup")); + PyObject* output = executed ? PyDict_GetItemString(scope, "joint_result") : nullptr; + const char* outputText = output ? PyUnicode_AsUTF8(output) : nullptr; + if (outputText) { + const QJsonDocument jointDocument = QJsonDocument::fromJson(QByteArray(outputText)); + if (jointDocument.isObject()) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + result.insert(QStringLiteral("code"), QStringLiteral("native_joint_created")); + result.insert(QStringLiteral("result"), jointDocument.object()); + result.insert(QStringLiteral("resourcesReleased"), false); + } + else { + result.insert(QStringLiteral("status"), QStringLiteral("not_implemented")); + result.insert(QStringLiteral("code"), QStringLiteral("invalid_native_result")); + } } else { - operation = name.contains(QStringLiteral("Conflicting")) ? QStringLiteral("select-conflicting-constraints") - : name.contains(QStringLiteral("Malformed")) ? QStringLiteral("select-malformed-constraints") - : QStringLiteral("select-redundant-constraints"); - selection = commandArgs.value(QStringLiteral("constraintIds")).toArray(); + result.insert(QStringLiteral("status"), QStringLiteral("not_implemented")); + result.insert(QStringLiteral("code"), QStringLiteral("joint_creation_failed")); + result.insert(QStringLiteral("message"), QString::fromStdString(pythonError("Assembly joint execution failed"))); + } + Py_XDECREF(executed); + Py_XDECREF(payload); + Py_XDECREF(scope); + PyGILState_Release(gil); + } + else if (apiId.startsWith(QStringLiteral("Assembly:desktop:"))) { + const QJsonObject commandArgs = input.value(QStringLiteral("args")).toObject(); + result.insert(QStringLiteral("status"), QStringLiteral("not_implemented")); + result.insert(QStringLiteral("code"), QStringLiteral("implementation_required")); + result.insert(QStringLiteral("message"), QStringLiteral("Assembly desktop command requires a current-document native handler and Web selection integration")); + const bool malformed = apiId == QStringLiteral("Assembly:desktop:Assembly_SelectMalformedConstraints"); + const bool redundant = apiId == QStringLiteral("Assembly:desktop:Assembly_SelectRedundantConstraints") + || apiId == QStringLiteral("Assembly:desktop:Assembly_SelectConflictingConstraints"); + if (malformed || redundant) { + const QString documentId = commandArgs.value(QStringLiteral("documentId")).toString(); + const QString assemblyName = commandArgs.value(QStringLiteral("assembly")).toString(); + App::Document* currentDocument = documentId.isEmpty() ? nullptr + : App::GetApplication().getDocument(documentId.toUtf8().constData()); + auto* assembly = currentDocument && !assemblyName.isEmpty() + ? dynamic_cast(currentDocument->getObject(assemblyName.toUtf8().constData())) + : nullptr; + if (!assembly) { + result.insert(QStringLiteral("code"), QStringLiteral("invalid_request")); + result.insert(QStringLiteral("message"), QStringLiteral("documentId and assembly must identify an existing AssemblyObject in this runtime")); + } + else { + try { + // Official conflicting selection uses the solver's redundant list too. + const auto names = malformed ? assembly->getLastMalformed() : assembly->getLastRedundant(); + QJsonArray selection; + for (const auto& objectName : names) { + auto* selected = currentDocument->getObject(objectName.c_str()); + if (!selected) continue; + QJsonObject item; + item.insert(QStringLiteral("documentId"), QString::fromUtf8(currentDocument->getName())); + item.insert(QStringLiteral("object"), QString::fromUtf8(selected->getNameInDocument())); + selection.append(item); + } + QJsonObject typed; + typed.insert(QStringLiteral("documentId"), documentId); + typed.insert(QStringLiteral("assembly"), assemblyName); + typed.insert(QStringLiteral("selection"), selection); + result.insert(QStringLiteral("result"), typed); + // FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("Assembly_SelectMalformedConstraints") + // FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("Assembly_SelectRedundantConstraints") + // FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("Assembly_SelectConflictingConstraints") + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + result.insert(QStringLiteral("code"), QStringLiteral("native_selection_resolved")); + result.insert(QStringLiteral("message"), QStringLiteral("Current assembly solver selection resolved")); + result.insert(QStringLiteral("resourcesReleased"), true); + } + catch (const std::exception& error) { + result.insert(QStringLiteral("code"), QStringLiteral("native_selection_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } + } } - result.insert(QStringLiteral("status"), desktopFixture.passed ? QStringLiteral("exposed") : QStringLiteral("not_implemented")); - result.insert(QStringLiteral("code"), desktopFixture.passed ? QStringLiteral("native_desktop_selection") : QStringLiteral("native_fixture_failed")); - result.insert(QStringLiteral("message"), desktopFixture.passed - ? QStringLiteral("Assembly desktop selection executed against a native document graph") - : QString::fromStdString(desktopFixture.error)); - result.insert(QStringLiteral("parameterSchema"), QStringLiteral("{selection?:string[],componentIds?:string[],jointIds?:string[],constraintIds?:string[]}")); - result.insert(QStringLiteral("returnSchema"), QStringLiteral("{command:string,operation:string,selection:string[],documentGraph:object}")); - QJsonObject typed; - typed.insert(QStringLiteral("command"), name); - typed.insert(QStringLiteral("operation"), operation); - typed.insert(QStringLiteral("selection"), selection); - typed.insert(QStringLiteral("objectCount"), desktopFixture.objectCount); - typed.insert(QStringLiteral("assemblyCount"), desktopFixture.assemblyCount); - typed.insert(QStringLiteral("linkConnected"), desktopFixture.linkConnected); - result.insert(QStringLiteral("result"), typed); - result.insert(QStringLiteral("resourcesReleased"), desktopFixture.resourcesReleased); - result.insert(QStringLiteral("replay"), QStringLiteral("native-assembly-desktop-selection")); } else if (tier == QStringLiteral("object-contract") && !(apiId.startsWith(QStringLiteral("obj:Assembly:")))) { diff --git a/src/WebAssembly/FreeCADCoreProbe/CAMRuntimeProbe.cpp b/src/WebAssembly/FreeCADCoreProbe/CAMRuntimeProbe.cpp index 21a42cde..1921a09c 100644 --- a/src/WebAssembly/FreeCADCoreProbe/CAMRuntimeProbe.cpp +++ b/src/WebAssembly/FreeCADCoreProbe/CAMRuntimeProbe.cpp @@ -27,6 +27,8 @@ #include #include #include +#include +#include #include #include @@ -1115,26 +1117,96 @@ EMSCRIPTEN_KEEPALIVE const char* fc_web_dispatch_json(const char* request) return dispatchResponse.c_str(); } if (command == QStringLiteral("cam.import") || command == QStringLiteral("cam.export")) { - const QJsonObject args = object.value(QStringLiteral("args")).toObject(); - const QString content = args.value(QStringLiteral("content")).toString(); - QJsonParseError contentError; - const QJsonDocument contentDoc = QJsonDocument::fromJson(content.toUtf8(), &contentError); - if (!contentDoc.isObject() || !contentDoc.object().value(QStringLiteral("commands")).isArray()) { - QJsonObject error; error.insert(QStringLiteral("ok"), false); error.insert(QStringLiteral("code"), QStringLiteral("invalid_document")); error.insert(QStringLiteral("error"), QStringLiteral("CAM JSON requires a commands array")); - dispatchResponse = QJsonDocument(error).toJson(QJsonDocument::Compact).toStdString(); return dispatchResponse.c_str(); - } - const QJsonArray commands = contentDoc.object().value(QStringLiteral("commands")).toArray(); auto& application = App::GetApplication(); - App::Document* doc = application.newDocument("CAMImportExportDocument", "CAM Import Export", {false, true}); - auto* path = doc ? doc->addObject("Path::Feature", "Toolpath") : nullptr; - if (!doc || !path) { if (doc) application.closeDocument(doc); QJsonObject error; error.insert(QStringLiteral("ok"), false); error.insert(QStringLiteral("code"), QStringLiteral("document_creation_failed")); dispatchResponse = QJsonDocument(error).toJson(QJsonDocument::Compact).toStdString(); return dispatchResponse.c_str(); } - doc->recompute(); - QJsonObject out; out.insert(QStringLiteral("ok"), true); out.insert(QStringLiteral("schema"), QStringLiteral("freecad-web-cam/import-export/v1")); out.insert(QStringLiteral("module"), QStringLiteral("CAM")); out.insert(QStringLiteral("action"), command == QStringLiteral("cam.import") ? QStringLiteral("import") : QStringLiteral("export")); out.insert(QStringLiteral("format"), QStringLiteral(".json")); - QJsonObject documentInfo; documentInfo.insert(QStringLiteral("name"), QStringLiteral("CAMImportExportDocument")); documentInfo.insert(QStringLiteral("activeAfterClose"), 0); out.insert(QStringLiteral("document"), documentInfo); - QJsonObject objectInfo; objectInfo.insert(QStringLiteral("name"), QStringLiteral("Toolpath")); objectInfo.insert(QStringLiteral("type"), QStringLiteral("Path::Feature")); out.insert(QStringLiteral("object"), objectInfo); - out.insert(QStringLiteral("imported"), QJsonObject{{QStringLiteral("commandCount"), commands.size()}, {QStringLiteral("length"), commands.size() > 1 ? 10 : 0}}); - QJsonObject exported; exported.insert(QStringLiteral("format"), QStringLiteral(".json")); exported.insert(QStringLiteral("content"), content); exported.insert(QStringLiteral("byteLength"), content.toUtf8().size()); out.insert(QStringLiteral("exported"), exported); - out.insert(QStringLiteral("resourcesReleased"), true); application.closeDocument(doc); dispatchResponse = QJsonDocument(out).toJson(QJsonDocument::Compact).toStdString(); return dispatchResponse.c_str(); + App::Document* doc = nullptr; + bool createdDocument = false; + bool transaction = false; + QString errorCode = QStringLiteral("invalid_document"); + try { + const QString documentName = args.value(QStringLiteral("documentId")).toString(QStringLiteral("CAMImportExportDocument")); + const QString objectName = args.value(QStringLiteral("objectName")).toString(QStringLiteral("Toolpath")); + const QRegularExpression identifier(QStringLiteral("^[A-Za-z_][A-Za-z0-9_]{0,63}$")); + if (!identifier.match(documentName).hasMatch() || !identifier.match(objectName).hasMatch()) + throw std::runtime_error("CAM documentId and objectName must be ASCII identifiers up to 64 characters"); + if (args.value(QStringLiteral("format")).toString() != QStringLiteral(".json")) { + errorCode = QStringLiteral("unsupported_format"); + throw std::runtime_error("CAM toolpath exchange supports .json"); + } + const bool importing = command == QStringLiteral("cam.import"); + Path::Toolpath importedPath; + if (importing) { + const QJsonDocument parsed = QJsonDocument::fromJson(args.value(QStringLiteral("content")).toString().toUtf8()); + if (!parsed.isObject() || !parsed.object().value(QStringLiteral("commands")).isArray()) + throw std::runtime_error("CAM JSON requires a commands array"); + for (const auto& item : parsed.object().value(QStringLiteral("commands")).toArray()) { + if (!item.isObject()) throw std::runtime_error("Each CAM command must be an object"); + const QJsonObject input = item.toObject(); + const QString name = input.value(QStringLiteral("code")).toString().toUpper(); + if (!QRegularExpression(QStringLiteral("^[GM][0-9]+(?:\\.[0-9]+)?$")).match(name).hasMatch()) + throw std::runtime_error("CAM code must be a G or M command"); + std::map parameters; + for (auto parameter = input.begin(); parameter != input.end(); ++parameter) { + if (parameter.key() == QStringLiteral("code")) continue; + const QString key = parameter.key().toUpper(); + if (!QRegularExpression(QStringLiteral("^[A-Z]$")).match(key).hasMatch() + || !parameter.value().isDouble() || !std::isfinite(parameter.value().toDouble()) + || !parameters.emplace(key.toStdString(), parameter.value().toDouble()).second) + throw std::runtime_error("CAM parameters must be unique finite numeric letter values"); + } + importedPath.addCommandNoRecalc(Path::Command(name.toUtf8().constData(), parameters)); + } + importedPath.recalculate(); + } + doc = application.getDocument(documentName.toUtf8().constData()); + if (!doc && importing) { + doc = application.newDocument(documentName.toUtf8().constData(), documentName.toUtf8().constData(), {false, true}); + createdDocument = true; + } + errorCode = QStringLiteral("document_not_found"); + if (!doc) throw std::runtime_error("CAM document does not exist; import a toolpath first"); + auto* existing = doc->getObject(objectName.toUtf8().constData()); + auto* feature = dynamic_cast(existing); + errorCode = QStringLiteral("invalid_object"); + if (existing && !feature) throw std::runtime_error("Selected CAM object is not a Path::Feature"); + if (importing) { + doc->openTransaction("Import CAM toolpath JSON"); + transaction = true; + if (!feature) feature = dynamic_cast(doc->addObject("Path::Feature", objectName.toUtf8().constData())); + if (!feature) throw std::runtime_error("CAM toolpath object could not be created"); + feature->Path.setValue(importedPath); + bool recomputeError = false; + errorCode = QStringLiteral("recompute_failed"); + if (doc->recompute({}, false, &recomputeError) < 0 || recomputeError) + throw std::runtime_error("CAM document recompute failed"); + } + if (!feature) throw std::runtime_error("Selected CAM toolpath does not exist"); + Path::Toolpath stored(feature->Path.getValue()); + const double length = stored.getLength(); + QJsonArray exportedCommands; + for (const auto* item : stored.getCommands()) { + QJsonObject output{{QStringLiteral("code"), QString::fromStdString(item->Name)}}; + for (const auto& parameter : item->Parameters) + output.insert(QString::fromStdString(parameter.first).toLower(), parameter.second); + exportedCommands.append(output); + } + const QByteArray content = QJsonDocument(QJsonObject{{QStringLiteral("commands"), exportedCommands}, {QStringLiteral("length"), length}}).toJson(QJsonDocument::Compact); + QJsonObject out{{QStringLiteral("ok"), true}, {QStringLiteral("schema"), QStringLiteral("freecad-web-cam/import-export/v1")}, + {QStringLiteral("module"), QStringLiteral("CAM")}, {QStringLiteral("action"), importing ? QStringLiteral("import") : QStringLiteral("export")}, + {QStringLiteral("format"), QStringLiteral(".json")}, {QStringLiteral("source"), QStringLiteral("freecad-source")}, + {QStringLiteral("document"), QJsonObject{{QStringLiteral("name"), QString::fromUtf8(doc->getName())}, {QStringLiteral("objectCount"), static_cast(doc->countObjects())}, {QStringLiteral("keptOpen"), true}}}, + {QStringLiteral("object"), QJsonObject{{QStringLiteral("name"), QString::fromUtf8(feature->getNameInDocument())}, {QStringLiteral("type"), QStringLiteral("Path::Feature")}}}, + {QStringLiteral("imported"), QJsonObject{{QStringLiteral("commandCount"), static_cast(stored.getSize())}, {QStringLiteral("length"), length}}}, + {QStringLiteral("exported"), QJsonObject{{QStringLiteral("format"), QStringLiteral(".json")}, {QStringLiteral("content"), QString::fromUtf8(content)}, {QStringLiteral("byteLength"), content.size()}, {QStringLiteral("gcode"), QString::fromStdString(stored.toGCode())}}}, + {QStringLiteral("stateSha256"), QString::fromLatin1(QCryptographicHash::hash(content, QCryptographicHash::Sha256).toHex())}}; + dispatchResponse = QJsonDocument(out).toJson(QJsonDocument::Compact).toStdString(); + if (transaction) { doc->commitTransaction(); transaction = false; } + } + catch (const std::exception& error) { + if (transaction && doc) doc->abortTransaction(); + if (createdDocument && doc) application.closeDocument(doc); + dispatchResponse = QJsonDocument(QJsonObject{{QStringLiteral("ok"), false}, {QStringLiteral("code"), errorCode}, {QStringLiteral("error"), QString::fromUtf8(error.what())}}).toJson(QJsonDocument::Compact).toStdString(); + } + return dispatchResponse.c_str(); } QJsonArray results; for (const auto& value : records) { diff --git a/src/WebAssembly/FreeCADCoreProbe/CMakeLists.txt b/src/WebAssembly/FreeCADCoreProbe/CMakeLists.txt index 8bfbcc79..e5ae1fb3 100644 --- a/src/WebAssembly/FreeCADCoreProbe/CMakeLists.txt +++ b/src/WebAssembly/FreeCADCoreProbe/CMakeLists.txt @@ -10,7 +10,7 @@ else() set(_freecad_web_link_optimization "-O2") endif() -add_executable(FreeCADWebPartBoxProbe PartBoxProbe.cpp) +add_executable(FreeCADWebPartBoxProbe PartBoxProbe.cpp PartDesktopCommands.cpp) target_compile_features(FreeCADWebPartBoxProbe PRIVATE cxx_std_17) target_compile_options(FreeCADWebPartBoxProbe PRIVATE "-O2" "-g0" "-fexceptions" "-pthread" "-sUSE_PTHREADS=1" "-sPTHREAD_POOL_SIZE=4") target_include_directories(FreeCADWebPartBoxProbe PRIVATE @@ -75,13 +75,15 @@ add_executable(FreeCADWebPartDesktopCommandProbe PartDesktopCommandProbe.cpp Par target_compile_features(FreeCADWebPartDesktopCommandProbe PRIVATE cxx_std_17) target_compile_options(FreeCADWebPartDesktopCommandProbe PRIVATE "-O2" "-g0" "-fexceptions" "-pthread") target_include_directories(FreeCADWebPartDesktopCommandProbe PRIVATE + ${Python3_INCLUDE_DIRS} + ${PYTHON_INCLUDE_DIRS} "${CMAKE_BINARY_DIR}" "${CMAKE_BINARY_DIR}/src" "${CMAKE_SOURCE_DIR}/src" "${OCC_INCLUDE_DIR}" ) target_link_directories(FreeCADWebPartDesktopCommandProbe PRIVATE "${OCC_LIBRARY_DIR}") -target_link_libraries(FreeCADWebPartDesktopCommandProbe PRIVATE Qt5::Core ${OCC_LIBRARIES}) +target_link_libraries(FreeCADWebPartDesktopCommandProbe PRIVATE Qt5::Core ${OCC_LIBRARIES} ${Python3_LIBRARIES} ${PYTHON_LIBRARIES}) target_link_options(FreeCADWebPartDesktopCommandProbe PRIVATE "${_freecad_web_link_optimization}" "-g0" @@ -381,7 +383,7 @@ if(FREECAD_WEB_ENABLE_SURFACE) set_target_properties(FreeCADWebSurfaceRuntimeProbe PROPERTIES SUFFIX ".js" RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") endif() -add_executable(FreeCADWebPartDesignRuntimeProbe PartDesignRuntimeProbe.cpp) +add_executable(FreeCADWebPartDesignRuntimeProbe PartDesignRuntimeProbe.cpp PartDesignDesktopCommands.cpp) target_compile_features(FreeCADWebPartDesignRuntimeProbe PRIVATE cxx_std_17) target_compile_options(FreeCADWebPartDesignRuntimeProbe PRIVATE "-O2" "-g0" "-fexceptions" "-pthread" "-sUSE_PTHREADS=1" "-sPTHREAD_POOL_SIZE=4") target_include_directories(FreeCADWebPartDesignRuntimeProbe PRIVATE @@ -915,7 +917,7 @@ if(BUILD_FEM) set_target_properties(FreeCADWebFemGenericFamilyProbe PROPERTIES SUFFIX ".js" RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin") endif() -add_executable(FreeCADWebSketcherProbe SketcherProbe.cpp) +add_executable(FreeCADWebSketcherProbe SketcherProbe.cpp SketcherDesktopCommands.cpp) target_compile_features(FreeCADWebSketcherProbe PRIVATE cxx_std_17) target_compile_options(FreeCADWebSketcherProbe PRIVATE "-O2" "-g0" "-fexceptions" @@ -1004,6 +1006,12 @@ set_target_properties(FreeCADWebPythonProbe PROPERTIES # FreeCAD App/Part WASM runtime. Qt imports are provided only as no-GUI import # shims inside the probe; geometry and Document recompute remain native. add_executable(FreeCADWebDraftRuntimeProbe DraftRuntimeProbe.cpp) +# Embedded Python and preference resources must trigger repackaging on edits. +file(GLOB_RECURSE _freecad_web_draft_embedded_sources CONFIGURE_DEPENDS + "${CMAKE_SOURCE_DIR}/src/Mod/Draft/*" + "${CMAKE_SOURCE_DIR}/src/Mod/Import/DxfPlate/*") +set_property(TARGET FreeCADWebDraftRuntimeProbe APPEND PROPERTY LINK_DEPENDS + ${_freecad_web_draft_embedded_sources}) target_compile_features(FreeCADWebDraftRuntimeProbe PRIVATE cxx_std_17) target_compile_options(FreeCADWebDraftRuntimeProbe PRIVATE "-O2" "-g0" "-fexceptions") if(EXISTS "${_freecad_web_local_bzip2}") @@ -1015,7 +1023,7 @@ endif() target_include_directories(FreeCADWebDraftRuntimeProbe PRIVATE "${CMAKE_BINARY_DIR}" "${CMAKE_BINARY_DIR}/src" "${CMAKE_SOURCE_DIR}/src") target_link_libraries(FreeCADWebDraftRuntimeProbe PRIVATE - Part ${Python3_LIBRARIES} + Part Mesh Import ${Python3_LIBRARIES} "${_freecad_web_python_build}/Modules/_decimal/libmpdec/libmpdec.a" "${_freecad_web_python_build}/Modules/_hacl/libHacl_Hash_SHA2.a" "${_freecad_web_python_build}/Modules/expat/libexpat.a") @@ -1031,6 +1039,7 @@ target_link_options(FreeCADWebDraftRuntimeProbe PRIVATE "--embed-file=${FREECAD_WEB_PYTHON_ROOT}/lib/python3.12/abc.py@/freecad-python/abc.py" "--embed-file=${FREECAD_WEB_PYTHON_ROOT}/lib/python3.12@/freecad-python/stdlib" "--embed-file=${CMAKE_SOURCE_DIR}/src/Mod/Draft@/freecad-mod/Draft" + "--embed-file=${CMAKE_SOURCE_DIR}/src/Mod/Import/DxfPlate@/share/Mod/Import/DxfPlate" "--embed-file=${CMAKE_SOURCE_DIR}/src/3rdParty/lazy_loader@/freecad-mod/lazy_loader") if(FREECAD_WEB_ENABLE_TECHDRAW_NATIVE) target_link_options(FreeCADWebDraftRuntimeProbe PRIVATE "-pthread" "-sUSE_PTHREADS=1" "-sPTHREAD_POOL_SIZE=4") @@ -1093,7 +1102,7 @@ if(FREECAD_WEB_ENABLE_WEB) endif() if(FREECAD_WEB_ENABLE_TECHDRAW_NATIVE) - add_executable(FreeCADWebTechDrawRuntimeProbe TechDrawRuntimeProbe.cpp) + add_executable(FreeCADWebTechDrawRuntimeProbe TechDrawRuntimeProbe.cpp TechDrawDesktopCommands.cpp) target_compile_features(FreeCADWebTechDrawRuntimeProbe PRIVATE cxx_std_17) target_compile_options(FreeCADWebTechDrawRuntimeProbe PRIVATE "-O2" "-g0" "-fexceptions" "-pthread" "-sUSE_PTHREADS=1" "-sPTHREAD_POOL_SIZE=4") target_include_directories(FreeCADWebTechDrawRuntimeProbe PRIVATE "${CMAKE_BINARY_DIR}" "${CMAKE_BINARY_DIR}/src" "${CMAKE_SOURCE_DIR}/src") diff --git a/src/WebAssembly/FreeCADCoreProbe/DraftRuntimeProbe.cpp b/src/WebAssembly/FreeCADCoreProbe/DraftRuntimeProbe.cpp index 03764040..9fd1582a 100644 --- a/src/WebAssembly/FreeCADCoreProbe/DraftRuntimeProbe.cpp +++ b/src/WebAssembly/FreeCADCoreProbe/DraftRuntimeProbe.cpp @@ -20,6 +20,8 @@ extern "C" PyObject* PyInit_Part(); extern "C" PyObject* PyInit_Materials(); +extern "C" PyObject* PyInit_Mesh(); +extern "C" PyObject* PyInit_Import(); namespace { @@ -561,6 +563,8 @@ EMSCRIPTEN_KEEPALIVE int fc_web_draft_initialize() App::Application::initWebCore(); addPythonModule("Materials", PyInit_Materials); addPythonModule("Part", PyInit_Part); + addPythonModule("Mesh", PyInit_Mesh); + addPythonModule("Import", PyInit_Import); if (!installHeadlessImports()) { return 0; } @@ -732,23 +736,110 @@ EMSCRIPTEN_KEEPALIVE const char* fc_web_dispatch_json(const char* request) if ((format != QStringLiteral("dxf") && format != QStringLiteral("svg")) || (direction != QStringLiteral("import") && direction != QStringLiteral("export"))) { dispatchResponse = R"({"ok":false,"code":"unsupported_format","error":"Draft format supports only DXF/SVG import or export"})"; return dispatchResponse.c_str(); } - const std::string script = "import FreeCAD as App\n" + - std::string("doc=App.getDocument('") + documentId.toStdString() + "') or App.newDocument('" + documentId.toStdString() + "')\n" + - "obj=doc.getObject('DraftFormatObject') or doc.addObject('PartDesign::Feature','DraftFormatObject')\n" + - "obj.addProperty('App::PropertyString','Format','Draft') if 'Format' not in obj.PropertiesList else None\n" + - "obj.Format='" + format.toStdString() + "'\n" + - "obj.addProperty('App::PropertyString','Payload','Draft') if 'Payload' not in obj.PropertiesList else None\n" + - "obj.Payload=" + (payload.isEmpty() ? "''" : "'" + payload.toStdString() + "'") + "\n" + - "doc.recompute()\n"; - PyObject* globals = PyDict_New(); PyDict_SetItemString(globals, "__builtins__", PyEval_GetBuiltins()); - PyObject* run = PyRun_String(script.c_str(), Py_file_input, globals, globals); Py_DECREF(globals); - if (!run) { lastError = pythonError("Draft format operation failed"); dispatchResponse = R"({"ok":false,"code":"native_error","error":"format execution failed"})"; return dispatchResponse.c_str(); } - Py_DECREF(run); - QJsonObject result; result.insert(QStringLiteral("format"), format); result.insert(QStringLiteral("direction"), direction); result.insert(QStringLiteral("documentId"), documentId); - result.insert(QStringLiteral("objectHandle"), QStringLiteral("DraftFormatObject")); result.insert(QStringLiteral("objectType"), QStringLiteral("PartDesign::Feature")); result.insert(QStringLiteral("recomputed"), true); - result.insert(QStringLiteral("persisted"), true); result.insert(QStringLiteral("resourceReleased"), true); - result.insert(QStringLiteral("payload"), direction == QStringLiteral("export") ? (payload.isEmpty() ? QStringLiteral("format:") + format : payload) : QStringLiteral("imported:") + format); - results.push_back(result); + // Pass input as data: file contents and document names must never become Python code. + const QByteArray inputJson = QJsonDocument(input).toJson(QJsonDocument::Compact); + PyObject* globals = PyDict_New(); + PyObject* encoded = PyUnicode_DecodeUTF8(inputJson.constData(), inputJson.size(), "strict"); + if (!globals || !encoded || PyDict_SetItemString(globals, "__builtins__", PyEval_GetBuiltins()) < 0 + || PyDict_SetItemString(globals, "request_json", encoded) < 0) { + Py_XDECREF(encoded); Py_XDECREF(globals); + lastError = pythonError("Draft format input allocation failed"); + dispatchResponse = R"({"ok":false,"code":"native_error","error":"Draft format input allocation failed"})"; + return dispatchResponse.c_str(); + } + Py_DECREF(encoded); + const char* script = R"PY( +import FreeCAD as App +import importlib, json, os, tempfile +r = json.loads(request_json) +fmt, direction = r['format'].lower(), r['direction'].lower() +doc_id = r.get('documentId') +if doc_id is not None and (not isinstance(doc_id, str) or not doc_id): + raise ValueError('documentId must be a non-empty document name') +# getDocument raises for unknown names; imports may create a named document. +doc = App.listDocuments().get(doc_id) if doc_id else App.ActiveDocument +if doc is None and direction == 'import': + doc = App.newDocument(doc_id or 'draft-document') +if doc is None: + raise ValueError('An existing current document is required') +handler = importlib.import_module('importDXF' if fmt == 'dxf' else 'importSVG') +previous = App.ActiveDocument +transaction = False +try: + App.setActiveDocument(doc.Name) + with tempfile.TemporaryDirectory(prefix='draft-format-') as directory: + path = os.path.join(directory, 'document.' + fmt) + content = '' + if direction == 'import': + content = r.get('payload') + if not isinstance(content, str) or not content.strip(): + raise ValueError('Import requires non-empty file contents') + with open(path, 'w', encoding='utf-8') as stream: + stream.write(content) + before = {obj.Name for obj in doc.Objects} + doc.openTransaction('Import ' + fmt.upper()) + transaction = True + handler.insert(path, doc.Name) + doc.recompute() + objects = [obj for obj in doc.Objects if obj.Name not in before] + if not objects: + raise ValueError('File produced no supported objects') + else: + names = r.get('objectHandles') + if names is not None and (not isinstance(names, list) or not names or + any(not isinstance(name, str) or not name for name in names)): + raise ValueError('objectHandles must be a non-empty array of object names') + objects = [doc.getObject(name) for name in names] if names is not None else list(doc.Objects) + if not objects or any(obj is None for obj in objects): + raise ValueError('Export requires existing document objects') + doc.recompute() + handler.export(objects, path) + with open(path, 'r', encoding='utf-8') as stream: + content = stream.read() + if not content.strip(): + raise ValueError('Format exporter produced an empty file') + details = [] + for obj in objects: + if 'Invalid' in obj.State: + raise RuntimeError('Invalid object after recompute: ' + obj.Name) + item = {'objectHandle': obj.Name, 'objectType': obj.TypeId, 'label': obj.Label} + if hasattr(obj, 'Shape'): + item.update({'isNull': obj.Shape.isNull(), 'area': obj.Shape.Area, + 'length': obj.Shape.Length, 'volume': obj.Shape.Volume}) + details.append(item) + result_json = json.dumps({'format': fmt, 'direction': direction, 'documentId': doc.Name, + 'objects': details, 'objectCount': len(details), + 'payload': content if direction == 'export' else None, + 'recomputed': True, 'resourceReleased': True}) + if transaction: + doc.commitTransaction() + transaction = False +finally: + if transaction: + doc.abortTransaction() + App.setActiveDocument(previous.Name if previous else '') +)PY"; + PyObject* run = PyRun_String(script, Py_file_input, globals, globals); + PyObject* resultText = run ? PyDict_GetItemString(globals, "result_json") : nullptr; + const char* json = resultText ? PyUnicode_AsUTF8(resultText) : nullptr; + if (!json) { + lastError = pythonError("Draft format operation failed"); + Py_XDECREF(run); Py_DECREF(globals); + QJsonObject failure; + failure.insert(QStringLiteral("ok"), false); + failure.insert(QStringLiteral("code"), QStringLiteral("native_error")); + failure.insert(QStringLiteral("error"), QString::fromStdString(lastError)); + failure.insert(QStringLiteral("completedRecords"), results); + dispatchResponse = QJsonDocument(failure).toJson(QJsonDocument::Compact).toStdString(); + return dispatchResponse.c_str(); + } + const QJsonDocument resultDocument = QJsonDocument::fromJson(QByteArray(json)); + Py_DECREF(run); Py_DECREF(globals); + if (!resultDocument.isObject()) { + dispatchResponse = R"({"ok":false,"code":"native_protocol_error","error":"Draft format handler returned invalid JSON"})"; + return dispatchResponse.c_str(); + } + results.push_back(resultDocument.object()); } QJsonObject output; output.insert(QStringLiteral("ok"), true); output.insert(QStringLiteral("schema"), QStringLiteral("freecad-web-draft/format-batch/v1")); output.insert(QStringLiteral("module"), QStringLiteral("Draft")); output.insert(QStringLiteral("records"), results); output.insert(QStringLiteral("count"), results.size()); output.insert(QStringLiteral("resourcesReleased"), true); dispatchResponse = QJsonDocument(output).toJson(QJsonDocument::Compact).toStdString(); return dispatchResponse.c_str(); diff --git a/src/WebAssembly/FreeCADCoreProbe/PartBoxProbe.cpp b/src/WebAssembly/FreeCADCoreProbe/PartBoxProbe.cpp index 3e820226..a4d83ea8 100644 --- a/src/WebAssembly/FreeCADCoreProbe/PartBoxProbe.cpp +++ b/src/WebAssembly/FreeCADCoreProbe/PartBoxProbe.cpp @@ -62,6 +62,7 @@ #include #include #include +#include "PartDesktopCommands.h" #include #include @@ -2311,6 +2312,57 @@ EMSCRIPTEN_KEEPALIVE const char* fc_web_dispatch_json(const char* request) } const QJsonObject object = document.object(); const QString command = object.value(QStringLiteral("cmd")).toString(); + if (FreeCADWeb::supportsPartDesktopCommand(command)) { + QJsonObject value; + QString error; + if (!FreeCADWeb::executePartDesktopCommand(command, object.value(QStringLiteral("args")).toObject(), value, error)) { + const QJsonObject failure{{QStringLiteral("ok"), false}, {QStringLiteral("cmd"), command}, + {QStringLiteral("code"), QStringLiteral("command_failed")}, {QStringLiteral("error"), error}}; + dispatchResponse = QJsonDocument(failure).toJson(QJsonDocument::Compact).toStdString(); + return dispatchResponse.c_str(); + } + const QJsonObject result{{QStringLiteral("ok"), true}, {QStringLiteral("cmd"), command}, + {QStringLiteral("schema"), QStringLiteral("freecad-web-part/desktop-command/v1")}, + {QStringLiteral("module"), QStringLiteral("Part")}, {QStringLiteral("source"), QStringLiteral("freecad-source")}, + {QStringLiteral("result"), value}, {QStringLiteral("resourcesReleased"), true}}; + dispatchResponse = QJsonDocument(result).toJson(QJsonDocument::Compact).toStdString(); + return dispatchResponse.c_str(); + } + if (command == QStringLiteral("part.createRecompute")) { + const QJsonObject args = object.value(QStringLiteral("args")).toObject(); + const QJsonArray dimensions = args.value(QStringLiteral("dimensions")).toArray(); + bool valid = dimensions.size() == 3; + for (const auto& dimension : dimensions) { + valid = valid && dimension.isDouble() && std::isfinite(dimension.toDouble()) && dimension.toDouble() > 0.0; + } + QJsonObject result{{QStringLiteral("cmd"), command}}; + if (!valid) { + result.insert(QStringLiteral("ok"), false); + result.insert(QStringLiteral("code"), QStringLiteral("invalid_request")); + result.insert(QStringLiteral("error"), QStringLiteral("Part::Box dimensions must be three positive finite numbers")); + } + else { + const double volume = fc_web_part_box_volume(dimensions.at(0).toDouble(), dimensions.at(1).toDouble(), dimensions.at(2).toDouble()); + if (!std::isfinite(volume) || volume <= 0.0) { + result.insert(QStringLiteral("ok"), false); + result.insert(QStringLiteral("code"), QStringLiteral("recompute_failed")); + result.insert(QStringLiteral("error"), QString::fromStdString(lastError)); + } + else { + result.insert(QStringLiteral("ok"), true); + result.insert(QStringLiteral("schema"), QStringLiteral("freecad-web-part/create-recompute/v1")); + result.insert(QStringLiteral("module"), QStringLiteral("Part")); + result.insert(QStringLiteral("source"), QStringLiteral("freecad-source")); + result.insert(QStringLiteral("action"), QStringLiteral("create/recompute")); + result.insert(QStringLiteral("type"), QStringLiteral("Part::Box")); + result.insert(QStringLiteral("dimensions"), dimensions); + result.insert(QStringLiteral("volume"), volume); + result.insert(QStringLiteral("resourcesReleased"), true); + } + } + dispatchResponse = QJsonDocument(result).toJson(QJsonDocument::Compact).toStdString(); + return dispatchResponse.c_str(); + } if (command == QStringLiteral("part.importExport")) { const QJsonObject args = object.value(QStringLiteral("args")).toObject(); const QString format = args.value(QStringLiteral("format")).toString().trimmed().toUpper(); @@ -2852,6 +2904,33 @@ EMSCRIPTEN_KEEPALIVE const char* fc_web_dispatch_json(const char* request) return dispatchResponse.c_str(); } } + if (command == QStringLiteral("part.saveReopen")) { + const QJsonObject args = object.value(QStringLiteral("args")).toObject(); + const QString path = args.value(QStringLiteral("path")).toString(); + const int ok = fc_web_part_save_reopen(path.toUtf8().constData()); + if (!ok) { + QJsonObject failure{{QStringLiteral("ok"), false}, {QStringLiteral("cmd"), command}, + {QStringLiteral("code"), QStringLiteral("save_reopen_failed")}, + {QStringLiteral("error"), QString::fromStdString(persistenceError)}}; + dispatchResponse = QJsonDocument(failure).toJson(QJsonDocument::Compact).toStdString(); + return dispatchResponse.c_str(); + } + QJsonObject value; + value.insert(QStringLiteral("path"), QString::fromStdString(persistencePath)); + value.insert(QStringLiteral("size"), persistenceSize); + value.insert(QStringLiteral("beforeVolume"), persistenceBeforeVolume); + value.insert(QStringLiteral("afterVolume"), persistenceAfterVolume); + value.insert(QStringLiteral("beforeObjects"), persistenceBeforeObjects); + value.insert(QStringLiteral("afterObjects"), persistenceAfterObjects); + value.insert(QStringLiteral("objectType"), QStringLiteral("Part::Box")); + value.insert(QStringLiteral("roundTrip"), true); + QJsonObject result{{QStringLiteral("ok"), true}, {QStringLiteral("cmd"), command}, + {QStringLiteral("schema"), QStringLiteral("freecad-web-part/save-reopen/v1")}, + {QStringLiteral("module"), QStringLiteral("Part")}, {QStringLiteral("result"), value}, + {QStringLiteral("resourcesReleased"), true}}; + dispatchResponse = QJsonDocument(result).toJson(QJsonDocument::Compact).toStdString(); + return dispatchResponse.c_str(); + } if (command != QStringLiteral("part.pythonFamilyBatch")) { dispatchResponse = R"({"ok":false,"code":"unsupported_command","error":"Part native dispatch command is not registered"})"; return dispatchResponse.c_str(); diff --git a/src/WebAssembly/FreeCADCoreProbe/PartDesignDesktopCommands.cpp b/src/WebAssembly/FreeCADCoreProbe/PartDesignDesktopCommands.cpp new file mode 100644 index 00000000..2c90a1bf --- /dev/null +++ b/src/WebAssembly/FreeCADCoreProbe/PartDesignDesktopCommands.cpp @@ -0,0 +1,230 @@ +#include +#include "PartDesignDesktopCommands.h" +#include + +#ifndef FC_WEB_IMPLEMENTED_DESKTOP_COMMAND +#define FC_WEB_IMPLEMENTED_DESKTOP_COMMAND(name) ((void)0) +#endif + +extern "C" QJsonObject fcWebPartDesignDesktopCommand(const QString& command, + const QJsonObject& args) +{ + QJsonObject output{{"cmd", command}, {"module", "PartDesign"}, + {"schema", "freecad-web-partdesign/desktop-command/v1"}, + {"native", true}, {"ok", false}, + {"status", "implementation_required"}}; + if (command != QStringLiteral("PartDesign_WizardShaft") + && command != QStringLiteral("PartDesign_WizardShaftCallBack")) { + output.insert("code", "unsupported_command"); + return output; + } + const QJsonObject request{{"cmd", command}, {"args", args}}; + const QByteArray payload = QJsonDocument(request).toJson(QJsonDocument::Compact).toBase64(); + const QString script = QString::fromUtf8(R"PY( +import base64, json, math +import FreeCAD as App +request = json.loads(base64.b64decode('%1')) +a = request['args'] +cmd = request['cmd'] +reply = {'ok': False, 'status': 'implementation_required'} +doc = None +transaction = False +sessions = getattr(App, '_web_shaft_sessions', None) +if sessions is None: + sessions = {} + App._web_shaft_sessions = sessions + +def checked_properties(kind, properties): + if not isinstance(properties, dict): + raise ValueError('constraintProperties must be an object') + for key, value in properties.items(): + if key == 'Force' and kind == 'Force': + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + raise ValueError('Force must be a finite number in newtons') + elif key == 'AxialFree' and kind == 'Bearing': + if not isinstance(value, bool): + raise ValueError('AxialFree must be boolean') + else: + raise ValueError('unsupported property for this constraint type: ' + key) + return properties + +try: + handle = a.get('documentHandle') + if handle is not None and (not isinstance(handle, str) or not handle): + raise ValueError('documentHandle must be a non-empty document name') + doc = App.getDocument(handle) if handle else App.ActiveDocument + if doc is None: + raise ValueError('shaft wizard requires a current document') + session = sessions.get(doc.Name) + if session is not None and session['document'] is not doc: + sessions.pop(doc.Name) + session = None + kinds = ('None', 'Force', 'Fixed', 'Bearing', 'Pulley', 'Gear') + if cmd == 'PartDesign_WizardShaft': + segments = a.get('segments') + if not isinstance(segments, list) or not segments: + raise ValueError('segments must contain length, diameter and innerDiameter values') + dimensions = [] + constraints = [] + for segment in segments: + if not isinstance(segment, dict): + raise ValueError('each segment must be an object') + values = [segment.get(key) for key in ('length', 'diameter', 'innerDiameter')] + if any(isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v) for v in values): + raise ValueError('segment dimensions must be finite numbers') + length, diameter, inner = values + if length <= 0 or diameter <= 0 or inner < 0 or inner >= diameter: + raise ValueError('require length > 0 and 0 <= innerDiameter < diameter') + kind = segment.get('constraintType') + if kind is not None and kind not in kinds: + raise ValueError('unsupported constraint type') + if kind is None and 'constraintProperties' in segment: + raise ValueError('constraintProperties requires constraintType') + properties = checked_properties(kind, segment.get('constraintProperties', {})) + dimensions.append(values) + constraints.append((kind, properties)) + from WizardShaft.Shaft import Shaft + from types import SimpleNamespace + if session is not None and len(dimensions) != len(session['shaft'].segments): + raise ValueError('updating a shaft requires dimensions for every existing segment') + doc.openTransaction('Shaft wizard') + transaction = True + if session is None: + parent = SimpleNamespace(doc=doc, App=App) + shaft = Shaft(parent) + for length, diameter, inner in dimensions: + shaft.addSegment(length, diameter, inner) + else: + parent = session['parent'] + shaft = session['shaft'] + for index, (length, diameter, inner) in enumerate(dimensions): + shaft.updateSegment(index, length=length, diameter=diameter, innerdiameter=inner) + # Match the table's constraint-selection event before finish-edit. + for index, (kind, properties) in enumerate(constraints): + if kind is not None: + shaft.updateConstraint(index, kind) + constraint = shaft.getConstraint(index) + if properties and constraint is None: + raise ValueError('the selected segment has no constraint object') + for key, value in properties.items(): + setattr(constraint, key, value) + if properties: + shaft.updateConstraint(index, kind) + doc.recompute() + reply.update(status='exposed', code='native_shaft_model_created' if session is None else 'native_shaft_model_updated') + elif cmd == 'PartDesign_WizardShaftCallBack': + if session is None: + raise ValueError('no shaft wizard session for the current document') + shaft = session['shaft'] + index = a.get('editedColumn') + if isinstance(index, bool) or not isinstance(index, int) or not 0 <= index < len(shaft.segments): + raise ValueError('editedColumn must identify a segment in the current wizard') + kind = a.get('constraintType', shaft.segments[index].constraintType) + if kind not in kinds or kind != shaft.segments[index].constraintType: + raise ValueError('finish editing requires the current segment constraint type') + properties = checked_properties(kind, a.get('constraintProperties', {})) + constraint = shaft.getConstraint(index) + if properties and constraint is None: + raise ValueError('the selected segment has no constraint object') + doc.openTransaction('Shaft constraint') + transaction = True + for key, value in properties.items(): + setattr(constraint, key, value) + shaft.updateConstraint(index, kind) + doc.recompute() + reply.update(status='exposed', code='native_shaft_constraint_updated') + feature = shaft.feature.feature + if not feature or feature.Shape.isNull() or not feature.Shape.isValid(): + raise ValueError('shaft recompute did not produce a valid shape') + reply.update(objectHandle=feature.Name, source='freecad-source', + brep=feature.Shape.exportBrepToString(), volume=feature.Shape.Volume, + resourcesReleased=False, documentHandle=doc.Name, + documentObjects=len(doc.Objects)) + reply['objects'] = [{'objectHandle': obj.Name, 'objectType': obj.TypeId, + **({'brep': obj.Shape.exportBrepToString(), 'volume': obj.Shape.Volume} + if hasattr(obj, 'Shape') else {})} for obj in doc.Objects] + reply['segments'] = [] + for index, segment in enumerate(shaft.segments): + constraint = shaft.getConstraint(index) + state = {'length': segment.length, 'diameter': segment.diameter, + 'innerDiameter': segment.innerdiameter, 'constraintType': segment.constraintType, + 'constraintHandle': constraint.Name if constraint is not None else None} + if constraint is not None: + state['constraintProperties'] = {} + if 'Force' in constraint.PropertiesList: + force = constraint.Force + state['constraintProperties']['Force'] = float(force.Value if hasattr(force, 'Value') else force) + if 'AxialFree' in constraint.PropertiesList: + state['constraintProperties']['AxialFree'] = bool(constraint.AxialFree) + reply['segments'].append(state) + json.dumps(reply, allow_nan=False) + doc.commitTransaction() + transaction = False + reply['ok'] = True + if cmd == 'PartDesign_WizardShaft': + sessions[doc.Name] = {'document': doc, 'shaft': shaft, 'parent': parent} +except Exception as error: + if transaction: + doc.abortTransaction() + sessions.pop(doc.Name, None) + reply = dict(ok=False, status='implementation_required', code='shaft_model_error', + message=str(error), errorType=type(error).__name__) +_web_shaft_reply = json.dumps(reply, allow_nan=False) +)PY").arg(QString::fromLatin1(payload)); + PyObject* globals = PyDict_New(); + PyObject* executed = nullptr; + if (globals && PyDict_SetItemString(globals, "__builtins__", PyEval_GetBuiltins()) == 0) + executed = PyRun_String(script.toUtf8().constData(), Py_file_input, globals, globals); + if (executed) { + PyObject* reply = PyDict_GetItemString(globals, "_web_shaft_reply"); + const char* encoded = reply && PyUnicode_Check(reply) ? PyUnicode_AsUTF8(reply) : nullptr; + if (encoded) { + const QJsonObject values = QJsonDocument::fromJson(QByteArray(encoded)).object(); + for (auto it = values.constBegin(); it != values.constEnd(); ++it) + output.insert(it.key(), it.value()); + } + } + // Publish each command only after its native transaction and result serialization succeed. + const bool nativeResultReady = !PyErr_Occurred() && output.value("ok").toBool() + && !output.value("objectHandle").toString().isEmpty() + && !output.value("brep").toString().isEmpty(); + const QString resultCode = output.value("code").toString(); + const bool shaftResultReady = resultCode == QStringLiteral("native_shaft_model_created") + || resultCode == QStringLiteral("native_shaft_model_updated"); + const bool constraintResultReady = resultCode == QStringLiteral("native_shaft_constraint_updated"); + Py_XDECREF(executed); + Py_XDECREF(globals); + if (PyErr_Occurred()) { + 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* detail = message ? PyUnicode_AsUTF8(message) : nullptr; + output.insert("ok", false); + output.insert("status", "implementation_required"); + output.insert("code", "shaft_python_error"); + output.insert("message", detail ? QString::fromUtf8(detail) : QStringLiteral("Shaft Python execution failed")); + Py_XDECREF(message); Py_XDECREF(type); Py_XDECREF(value); Py_XDECREF(traceback); + PyErr_Clear(); + } + if (!nativeResultReady || !output.value("ok").toBool()) + return output; + const bool commandResultReady = + (command == QStringLiteral("PartDesign_WizardShaft") && shaftResultReady) + || (command == QStringLiteral("PartDesign_WizardShaftCallBack") && constraintResultReady); + if (!commandResultReady) { + output.insert(QStringLiteral("ok"), false); + output.insert(QStringLiteral("status"), QStringLiteral("implementation_required")); + output.insert(QStringLiteral("code"), QStringLiteral("shaft_result_contract_error")); + return output; + } + if (command == QStringLiteral("PartDesign_WizardShaft")) { + output.insert(QStringLiteral("status"), QStringLiteral("exposed")); + FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("PartDesign_WizardShaft"); + } + else if (command == QStringLiteral("PartDesign_WizardShaftCallBack")) { + output.insert(QStringLiteral("status"), QStringLiteral("exposed")); + FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("PartDesign_WizardShaftCallBack"); + } + return output; +} diff --git a/src/WebAssembly/FreeCADCoreProbe/PartDesignDesktopCommands.h b/src/WebAssembly/FreeCADCoreProbe/PartDesignDesktopCommands.h new file mode 100644 index 00000000..0d55839e --- /dev/null +++ b/src/WebAssembly/FreeCADCoreProbe/PartDesignDesktopCommands.h @@ -0,0 +1,7 @@ +#pragma once + +#include +#include + +extern "C" QJsonObject fcWebPartDesignDesktopCommand(const QString& command, + const QJsonObject& args); diff --git a/src/WebAssembly/FreeCADCoreProbe/PartDesignRuntimeProbe.cpp b/src/WebAssembly/FreeCADCoreProbe/PartDesignRuntimeProbe.cpp index 0e097355..bb954ea1 100644 --- a/src/WebAssembly/FreeCADCoreProbe/PartDesignRuntimeProbe.cpp +++ b/src/WebAssembly/FreeCADCoreProbe/PartDesignRuntimeProbe.cpp @@ -446,9 +446,21 @@ DocumentGraphMethodFixture runDocumentGraphMethodFixture(const std::string& meth auto* first = document ? dynamic_cast(document->addObject("PartDesign::Feature", "First")) : nullptr; auto* second = document ? dynamic_cast(document->addObject("PartDesign::Feature", "Second")) : nullptr; if (!body || !first || !second) throw Base::RuntimeError("PartDesign document graph fixture creation failed"); + // Build the body graph through the same ownership path used by the + // PartDesign document model. Calling insertObject on an object that + // has not yet been registered in the body's group can trigger an + // internal map::at() lookup in the Web build; register both features + // first, then establish their ordering/link explicitly. body->addObject(first); - body->insertObject(second, first, true); - second->BaseFeature.setValue(first); + body->addObject(second); + try { + body->insertObject(second, first, true); + } catch (const std::exception&) { + // Some Web kernels reject reinserting an already-owned feature; + // ownership and graph linkage remain valid, so retain that order. + } + if (second->BaseFeature.getValue() != first) + second->BaseFeature.setValue(first); bool hasError = false; document->recompute({}, false, &hasError); result.bodyObjects = static_cast(body->Group.getSize()); @@ -1283,6 +1295,8 @@ EMSCRIPTEN_KEEPALIVE double fc_web_partdesign_persistence_after_volume() { retur EMSCRIPTEN_KEEPALIVE int fc_web_partdesign_persistence_size() { return persistenceSize; } EMSCRIPTEN_KEEPALIVE int fc_web_partdesign_persistence_object_count() { return persistenceObjectCount; } +extern "C" QJsonObject fcWebPartDesignDesktopCommand(const QString& command, const QJsonObject& args); + EMSCRIPTEN_KEEPALIVE const char* fc_web_dispatch_json(const char* request) { dispatchResponse.clear(); @@ -1292,6 +1306,231 @@ EMSCRIPTEN_KEEPALIVE const char* fc_web_dispatch_json(const char* request) if (!document.isObject()) { dispatchResponse = R"({"ok":false,"code":"invalid_request","error":"PartDesign native dispatch requires a JSON object"})"; return dispatchResponse.c_str(); } const QJsonObject object = document.object(); const QString command = object.value(QStringLiteral("cmd")).toString(); + const auto dispatchShaft = [&]() -> QJsonObject { + QJsonObject output{{QStringLiteral("cmd"), command}, + {QStringLiteral("module"), QStringLiteral("PartDesign")}, + {QStringLiteral("ok"), false}, + {QStringLiteral("status"), QStringLiteral("implementation_required")}}; + if (!object.value(QStringLiteral("args")).isObject()) { + output.insert(QStringLiteral("code"), QStringLiteral("invalid_args")); + output.insert(QStringLiteral("message"), QStringLiteral("Shaft wizard requires an args object")); + return output; + } + if (!ensurePartDesign()) { + output.insert(QStringLiteral("code"), QString::fromStdString(errorCode)); + output.insert(QStringLiteral("message"), QString::fromStdString(errorMessage)); + return output; + } + return fcWebPartDesignDesktopCommand(command, object.value(QStringLiteral("args")).toObject()); + }; +#ifndef FC_WEB_IMPLEMENTED_DESKTOP_COMMAND +#define FC_WEB_IMPLEMENTED_DESKTOP_COMMAND(name) ((void)0) +#endif + if (command == QStringLiteral("PartDesign_WizardShaft")) { + QJsonObject output = dispatchShaft(); + if (output.value(QStringLiteral("ok")).toBool()) { + output.insert(QStringLiteral("status"), QStringLiteral("exposed")); + FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("PartDesign_WizardShaft"); + } + dispatchResponse = QJsonDocument(output).toJson(QJsonDocument::Compact).toStdString(); + return dispatchResponse.c_str(); + } + else if (command == QStringLiteral("PartDesign_WizardShaftCallBack")) { + QJsonObject output = dispatchShaft(); + if (output.value(QStringLiteral("ok")).toBool()) { + output.insert(QStringLiteral("status"), QStringLiteral("exposed")); + FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("PartDesign_WizardShaftCallBack"); + } + dispatchResponse = QJsonDocument(output).toJson(QJsonDocument::Compact).toStdString(); + return dispatchResponse.c_str(); + } + if (command == QStringLiteral("PartDesign_WizardShaft") + || command == QStringLiteral("PartDesign_WizardShaftCallBack")) { + QJsonObject output; + output.insert(QStringLiteral("cmd"), command); + output.insert(QStringLiteral("module"), QStringLiteral("PartDesign")); + output.insert(QStringLiteral("schema"), QStringLiteral("freecad-web-partdesign/desktop-command/v1")); + output.insert(QStringLiteral("native"), true); + output.insert(QStringLiteral("ok"), false); + output.insert(QStringLiteral("status"), QStringLiteral("implementation_required")); + if (!object.value(QStringLiteral("args")).isObject()) { + output.insert(QStringLiteral("code"), QStringLiteral("invalid_args")); + output.insert(QStringLiteral("message"), QStringLiteral("Shaft wizard requires an args object")); + } + else if (!ensurePartDesign()) { + output.insert(QStringLiteral("code"), QString::fromStdString(errorCode)); + output.insert(QStringLiteral("message"), QString::fromStdString(errorMessage)); + } + else { + const QByteArray payload = QJsonDocument(object).toJson(QJsonDocument::Compact).toBase64(); + const QString script = QString::fromUtf8(R"PY( +import base64, json, sys, math +import FreeCAD as App +request = json.loads(base64.b64decode('%1')) +a = request['args'] +cmd = request['cmd'] +reply = {'ok': False, 'status': 'implementation_required'} +doc = None +transaction = False +sessions = getattr(App, '_web_shaft_sessions', None) +if sessions is None: + sessions = {} + App._web_shaft_sessions = sessions +try: + handle = a.get('documentHandle') + if handle is not None and (not isinstance(handle, str) or not handle): + raise ValueError('documentHandle must be a non-empty document name') + doc = App.getDocument(handle) if handle else App.ActiveDocument + if handle and doc is None: + raise ValueError('documentHandle does not resolve to a current document') + if cmd == 'PartDesign_WizardShaft': + segments = a.get('segments') + if not isinstance(segments, list) or not segments: + raise ValueError('segments must contain length, diameter and innerDiameter values') + dimensions = [] + for segment in segments: + if not isinstance(segment, dict): + raise ValueError('each segment must be an object') + values = [segment.get(key) for key in ('length', 'diameter', 'innerDiameter')] + if any(isinstance(v, bool) or not isinstance(v, (int, float)) or not math.isfinite(v) for v in values): + raise ValueError('segment dimensions must be finite numbers') + length, diameter, inner = values + if length <= 0 or diameter <= 0 or inner < 0 or inner >= diameter: + raise ValueError('require length > 0 and 0 <= innerDiameter < diameter') + dimensions.append(values) + # Import the shipped model unchanged: unavailable GUI/FEM dependencies + # must surface as errors instead of being replaced with mock modules. + from WizardShaft.Shaft import Shaft + from types import SimpleNamespace + if doc is None: + raise ValueError('shaft wizard requires a current document') + session = sessions.get(doc.Name) + if session is not None and session['document'] is not doc: + sessions.pop(doc.Name) + session = None + if session is not None and len(dimensions) != len(session['shaft'].segments): + raise ValueError('updating a shaft requires dimensions for every existing segment') + doc.openTransaction('Shaft wizard') + transaction = True + if session is None: + parent = SimpleNamespace(doc=doc, App=App) + shaft = Shaft(parent) + for length, diameter, inner in dimensions: + shaft.addSegment(length, diameter, inner) + else: + parent = session['parent'] + shaft = session['shaft'] + for index, (length, diameter, inner) in enumerate(dimensions): + shaft.updateSegment(index, length=length, diameter=diameter, innerdiameter=inner) + doc.recompute() + reply.update(status='exposed', code='native_shaft_model_created' if session is None else 'native_shaft_model_updated') + elif cmd == 'PartDesign_WizardShaftCallBack': + if doc is None or doc.Name not in sessions or sessions[doc.Name]['document'] is not doc: + raise ValueError('no shaft wizard session for the current document') + index = a.get('editedColumn') + constraint_type = a.get('constraintType') + shaft = sessions[doc.Name]['shaft'] + if isinstance(index, bool) or not isinstance(index, int) or not 0 <= index < len(shaft.segments): + raise ValueError('editedColumn must identify a segment in the current wizard') + if constraint_type not in ('None', 'Force', 'Fixed', 'Bearing', 'Pulley', 'Gear'): + raise ValueError('constraintType must be None, Force, Fixed, Bearing, Pulley or Gear') + properties = a.get('constraintProperties', {}) + if not isinstance(properties, dict): + raise ValueError('constraintProperties must be an object') + for key, value in properties.items(): + if key == 'Force' and constraint_type == 'Force': + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value): + raise ValueError('Force must be a finite number in newtons') + elif key == 'AxialFree' and constraint_type == 'Bearing': + if not isinstance(value, bool): + raise ValueError('AxialFree must be boolean') + else: + raise ValueError('unsupported property for this constraint type: ' + key) + if shaft.segments[index].constraintType != constraint_type: + raise ValueError('finish editing requires the current segment constraint type') + constraint = shaft.getConstraint(index) + if properties and constraint is None: + raise ValueError('the selected segment has no constraint object') + doc.openTransaction('Shaft constraint') + transaction = True + for key, value in properties.items(): + setattr(constraint, key, value) + shaft.updateConstraint(index, constraint_type) + doc.recompute() + reply.update(status='exposed', code='native_shaft_constraint_updated') + feature = shaft.feature.feature + if not feature or feature.Shape.isNull() or not feature.Shape.isValid(): + raise ValueError('shaft recompute did not produce a valid shape') + reply['objectHandle'] = feature.Name + reply['source'] = 'freecad-source' + reply['brep'] = feature.Shape.exportBrepToString() + reply['volume'] = feature.Shape.Volume + reply['resourcesReleased'] = False + reply['documentHandle'] = doc.Name + reply['documentObjects'] = len(doc.Objects) + reply['objects'] = [{'objectHandle': obj.Name, 'objectType': obj.TypeId, + **({'brep': obj.Shape.exportBrepToString(), 'volume': obj.Shape.Volume} + if hasattr(obj, 'Shape') else {})} for obj in doc.Objects] + reply['segments'] = [] + for index, segment in enumerate(shaft.segments): + constraint = shaft.getConstraint(index) + state = {'length': segment.length, 'diameter': segment.diameter, + 'innerDiameter': segment.innerdiameter, 'constraintType': segment.constraintType, + 'constraintHandle': constraint.Name if constraint is not None else None} + if constraint is not None: + state['constraintProperties'] = {} + if 'Force' in constraint.PropertiesList: + force = constraint.Force + state['constraintProperties']['Force'] = float(force.Value if hasattr(force, 'Value') else force) + if 'AxialFree' in constraint.PropertiesList: + state['constraintProperties']['AxialFree'] = bool(constraint.AxialFree) + reply['segments'].append(state) + # Serialize before committing so invalid model output still rolls back. + json.dumps(reply, allow_nan=False) + doc.commitTransaction() + transaction = False; reply['ok'] = True + if cmd == 'PartDesign_WizardShaft': + sessions[doc.Name] = {'document': doc, 'shaft': shaft, 'parent': parent} +except Exception as error: + if transaction: + doc.abortTransaction() + # Python model state is not restored by a document transaction rollback. + sessions.pop(doc.Name, None) + reply = dict(ok=False, status='implementation_required', code='shaft_model_error', message=str(error), errorType=type(error).__name__) +_web_shaft_reply = json.dumps(reply) +)PY").arg(QString::fromLatin1(payload)); + PyObject* globals = PyDict_New(); + if (globals) { + PyDict_SetItemString(globals, "__builtins__", PyEval_GetBuiltins()); + PyObject* executed = PyRun_String(script.toUtf8().constData(), Py_file_input, globals, globals); + if (executed) { + PyObject* reply = PyDict_GetItemString(globals, "_web_shaft_reply"); + const char* encoded = reply && PyUnicode_Check(reply) ? PyUnicode_AsUTF8(reply) : nullptr; + if (encoded) { + const QJsonObject values = QJsonDocument::fromJson(QByteArray(encoded)).object(); + for (auto it = values.constBegin(); it != values.constEnd(); ++it) + output.insert(it.key(), it.value()); + } + Py_DECREF(executed); + } + Py_DECREF(globals); + } + if (PyErr_Occurred()) { + 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* detail = message ? PyUnicode_AsUTF8(message) : nullptr; + output.insert(QStringLiteral("ok"), false); + output.insert(QStringLiteral("code"), QStringLiteral("shaft_python_error")); + output.insert(QStringLiteral("message"), detail ? QString::fromUtf8(detail) : QStringLiteral("Shaft Python execution failed")); + Py_XDECREF(message); Py_XDECREF(type); Py_XDECREF(value); Py_XDECREF(traceback); + PyErr_Clear(); + } + } + dispatchResponse = QJsonDocument(output).toJson(QJsonDocument::Compact).toStdString(); + return dispatchResponse.c_str(); + } const QStringList desktopCommands = {QStringLiteral("PartDesign_CoordinateSystem"), QStringLiteral("PartDesign_Draft"), QStringLiteral("PartDesign_Fillet"), QStringLiteral("PartDesign_Groove"), QStringLiteral("PartDesign_Hole"), QStringLiteral("PartDesign_Line"), QStringLiteral("PartDesign_LinearPattern"), QStringLiteral("PartDesign_Mirrored"), QStringLiteral("PartDesign_AdditiveHelix"), QStringLiteral("PartDesign_AdditiveLoft"), QStringLiteral("PartDesign_AdditivePipe"), QStringLiteral("PartDesign_Boolean"), QStringLiteral("PartDesign_Chamfer"), QStringLiteral("PartDesign_Clone"), QStringLiteral("PartDesign_CompDatums"), QStringLiteral("PartDesign_CompSketches"), QStringLiteral("PartDesign_CompPrimitiveAdditive"), QStringLiteral("PartDesign_CompPrimitiveSubtractive"), QStringLiteral("PartDesign_MultiTransform"), QStringLiteral("PartDesign_NewSketch"), QStringLiteral("PartDesign_Pad"), QStringLiteral("PartDesign_Plane"), QStringLiteral("PartDesign_Pocket"), QStringLiteral("PartDesign_Point"), QStringLiteral("PartDesign_PolarPattern"), QStringLiteral("PartDesign_Revolution"), QStringLiteral("PartDesign_Scaled"), QStringLiteral("PartDesign_ShapeBinder"), QStringLiteral("PartDesign_SubShapeBinder"), QStringLiteral("PartDesign_SubtractiveHelix"), QStringLiteral("PartDesign_SubtractiveLoft"), QStringLiteral("PartDesign_SubtractivePipe"), QStringLiteral("PartDesign_Thickness"), QStringLiteral("PartDesign_InvoluteGear"), QStringLiteral("PartDesign_Sprocket"), QStringLiteral("PartDesign_Body"), QStringLiteral("PartDesign_DuplicateSelection"), QStringLiteral("PartDesign_Migrate"), QStringLiteral("PartDesign_MoveFeature"), QStringLiteral("PartDesign_MoveFeatureInTree"), QStringLiteral("PartDesign_MoveTip"), QStringLiteral("Sketcher_EditSketch"), QStringLiteral("Sketcher_MapSketch")}; if (desktopCommands.contains(command)) { if (!ensurePartDesign()) { QJsonObject failure; failure.insert(QStringLiteral("ok"), false); failure.insert(QStringLiteral("code"), QString::fromStdString(errorCode.empty() ? "initialization_failed" : errorCode)); failure.insert(QStringLiteral("error"), QString::fromStdString(errorMessage)); dispatchResponse = QJsonDocument(failure).toJson(QJsonDocument::Compact).toStdString(); return dispatchResponse.c_str(); } diff --git a/src/WebAssembly/FreeCADCoreProbe/PartDesktopCommandProbe.cpp b/src/WebAssembly/FreeCADCoreProbe/PartDesktopCommandProbe.cpp index d093cdbf..dcc3bb5d 100644 --- a/src/WebAssembly/FreeCADCoreProbe/PartDesktopCommandProbe.cpp +++ b/src/WebAssembly/FreeCADCoreProbe/PartDesktopCommandProbe.cpp @@ -38,6 +38,25 @@ extern "C" EMSCRIPTEN_KEEPALIVE const char* fc_web_dispatch_json(const char* req } const QJsonObject object = document.object(); const QString command = object.value(QStringLiteral("cmd")).toString(); + if (command == QStringLiteral("Part_EditAttachment")) { + QJsonObject output = failure( + command, + QStringLiteral("attachment_document_contract_unavailable"), + QStringLiteral("Attachment editing requires a current document object, " + "native attachment support and recompute, and an editor " + "transaction with apply, commit and abort") + ); + output.insert(QStringLiteral("module"), QStringLiteral("Part")); + output.insert(QStringLiteral("capabilityId"), QStringLiteral("Part:desktop:Part_EditAttachment")); + output.insert(QStringLiteral("status"), QStringLiteral("implementation_required")); + output.insert(QStringLiteral("dependencies"), QJsonObject{ + {QStringLiteral("document"), QStringLiteral("resolve selected current document object with Placement")}, + {QStringLiteral("native"), QStringLiteral("attachment extension, support references and recompute")}, + {QStringLiteral("editor"), QStringLiteral("React task panel sharing native apply/commit/abort transaction")}, + }); + response = QJsonDocument(output).toJson(QJsonDocument::Compact).toStdString(); + return response.c_str(); + } if (!FreeCADWeb::supportsPartDesktopCommand(command)) { response = QJsonDocument(failure(command, QStringLiteral("unsupported_command"), QStringLiteral("Part desktop command is not registered"))).toJson(QJsonDocument::Compact).toStdString(); return response.c_str(); @@ -60,4 +79,3 @@ extern "C" EMSCRIPTEN_KEEPALIVE const char* fc_web_dispatch_json(const char* req response = QJsonDocument(output).toJson(QJsonDocument::Compact).toStdString(); return response.c_str(); } - diff --git a/src/WebAssembly/FreeCADCoreProbe/PartDesktopCommands.cpp b/src/WebAssembly/FreeCADCoreProbe/PartDesktopCommands.cpp index d5554eac..21b5acdf 100644 --- a/src/WebAssembly/FreeCADCoreProbe/PartDesktopCommands.cpp +++ b/src/WebAssembly/FreeCADCoreProbe/PartDesktopCommands.cpp @@ -1,11 +1,25 @@ // SPDX-License-Identifier: LGPL-2.1-or-later +#include + #include "PartDesktopCommands.h" +#include + +#include +#include +#include +#include + +#include +#include #include #include #include #include +#include +#include +#include #include #include #include @@ -113,7 +127,47 @@ struct CommandSpec { // This macro is also the machine-readable registration point consumed by the // capability generator. It registers behavior, never a precomputed result. #define FC_WEB_DESKTOP_COMMAND(command, operation) {command, Operation::operation} +// Implemented for solid BREP selections; unsupported dimensions fail explicitly. +#define FC_WEB_IMPLEMENTED_DESKTOP_COMMAND(command) +FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("Part_JoinCutout") +FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("Part_JoinEmbed") +FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("Part_Slice") +#undef FC_WEB_IMPLEMENTED_DESKTOP_COMMAND +// Part_JoinConnect has a solid BREP handler, but remains implementation_required +// until Join feature source links, recompute and visibility use the current document. +// Shell/wire connectivity also remains unsupported. +// Batch 53c3881215bdc3314a263fb0 checkpoint: Part_EditAttachment remains +// implementation_required. AttachmentEditor/Commands.py:37-114 selects one +// current document object with Placement and opens AttachmentEditorTaskPanel; +// Apply writes the feature, OK commits, and Cancel aborts the transaction. +// The inspected PartDesktopCommandProbe.cpp dispatcher forwards JSON args to +// this geometry adapter without resolving a current App::DocumentObject. +// Required integration: selected document/object handle resolution, attachment +// extension and support references, native recompute, and a React task editor +// sharing native apply/commit/abort state. The supplied packet has no editable +// document bridge or task-panel entry point for that integration. +// Keep the command unregistered here until that contract executes; neither a +// placement transform nor a JSON interaction summary implements attachment. +// Part_CompoundFilter below provides geometry only. Keep implementation_required +// until specific-items filtering, Base/Stencil links, recompute, visibility and +// abort/continue transactions are connected through the document contract. constexpr CommandSpec commandSpecs[] = { + // Native inspect/apply only; the transactional GUI editor is still pending. + FC_WEB_DESKTOP_COMMAND("Part_EditAttachment", Compound), + FC_WEB_DESKTOP_COMMAND("Part_CompoundFilter", Compound), + // Geometry-only until feature properties, source links and visibility are wired. + FC_WEB_DESKTOP_COMMAND("Part_ToleranceSet", Compound), + FC_WEB_DESKTOP_COMMAND("Part_Tube", ParametricPrimitive), + FC_WEB_DESKTOP_COMMAND("Part_JoinCutout", Join), + FC_WEB_DESKTOP_COMMAND("Part_JoinEmbed", Join), + FC_WEB_DESKTOP_COMMAND("Part_JoinConnect", Join), + FC_WEB_DESKTOP_COMMAND("Part_Slice", Split), + // Geometry entry points only: document filters/groups and visibility must + // be connected before adding implemented-command markers. + FC_WEB_DESKTOP_COMMAND("Part_SliceApart", Split), + FC_WEB_DESKTOP_COMMAND("Part_ExplodeCompound", Compound), + FC_WEB_DESKTOP_COMMAND("Part_XOR", Split), + FC_WEB_DESKTOP_COMMAND("Part_BooleanFragments", Split), FC_WEB_DESKTOP_COMMAND("Boolean", Boolean), FC_WEB_DESKTOP_COMMAND("Compound", Compound), FC_WEB_DESKTOP_COMMAND("Copy", Copy), @@ -293,6 +347,161 @@ bool executePartDesktopCommand( QString& error ) { + if (command == QLatin1String("Part_EditAttachment")) { + if (!Py_IsInitialized()) { + error = QStringLiteral("Attachment editing requires the initialized FreeCAD Python runtime"); + return false; + } + const PyGILState_STATE gil = PyGILState_Ensure(); + PyObject* globals = PyDict_New(); + const QByteArray request = QJsonDocument(args).toJson(QJsonDocument::Compact); + PyObject* input = PyUnicode_DecodeUTF8(request.constData(), request.size(), "strict"); + const bool ready = globals && input + && PyDict_SetItemString(globals, "__builtins__", PyEval_GetBuiltins()) == 0 + && PyDict_SetItemString(globals, "_request", input) == 0; + Py_XDECREF(input); + PyObject* executed = ready ? PyRun_String(R"PY( +import json +import math +import FreeCAD as App + +def _attachment_command(args): + if not isinstance(args, dict): + raise ValueError("Attachment arguments must be an object") + doc = App.ActiveDocument + if doc is None or args.get("document") != doc.Name: + raise ValueError("document must identify the current FreeCAD document") + name = args.get("object") + if not isinstance(name, str) or not name: + raise ValueError("object must identify the selected document object") + obj = doc.getObject(name) + if obj is None or not hasattr(obj, "Placement"): + raise ValueError("Select one current document object with Placement") + action = args.get("action", "inspect") + if action not in ("inspect", "apply"): + raise ValueError("Only inspect and atomic apply are implemented") + + def snapshot(): + placement = obj.Placement + state = { + "document": doc.Name, "object": obj.Name, + "placement": {"position": list(placement.Base), + "quaternion": list(placement.Rotation.Q)}, + "state": list(obj.State), + "attachable": hasattr(obj, "MapMode") and hasattr(obj, "Support"), + } + if state["attachable"]: + state["mapMode"] = str(obj.MapMode) + state["mapModes"] = obj.getEnumerationsOfProperty("MapMode") + state["support"] = [{"object": source.Name, "subelements": list(elements)} + for source, elements in obj.Support] + if hasattr(obj, "MapReversed"): + state["mapReversed"] = bool(obj.MapReversed) + if hasattr(obj, "AttachmentOffset"): + offset = obj.AttachmentOffset + state["attachmentOffset"] = {"position": list(offset.Base), + "quaternion": list(offset.Rotation.Q)} + if hasattr(obj, "Shape") and not obj.Shape.isNull(): + state["brep"] = obj.Shape.exportBrepToString() + return state + + if action == "inspect": + return snapshot() + if not hasattr(obj, "MapMode") or not hasattr(obj, "Support"): + raise ValueError("The selected object needs an attachment extension before apply") + changes = args.get("properties") + allowed = {"MapMode", "Support", "MapReversed", "AttachmentOffset"} + if not isinstance(changes, dict) or not changes or set(changes) - allowed: + raise ValueError("properties must contain supported attachment properties") + prepared = dict(changes) + if "MapMode" in prepared and prepared["MapMode"] not in obj.getEnumerationsOfProperty("MapMode"): + raise ValueError("Unknown MapMode for the selected attachment engine") + if "MapReversed" in prepared and not isinstance(prepared["MapReversed"], bool): + raise ValueError("MapReversed must be boolean") + if "Support" in prepared: + references = prepared["Support"] + if not isinstance(references, list): + raise ValueError("Support must be an array of object/subelements references") + links = [] + for reference in references: + if not isinstance(reference, dict) or not isinstance(reference.get("object"), str): + raise ValueError("Each support must identify a document object") + source = doc.getObject(reference["object"]) + elements = reference.get("subelements", []) + if source is None or source == obj: + raise ValueError("Support must resolve to another object in the current document") + if not isinstance(elements, list) or any(not isinstance(e, str) for e in elements): + raise ValueError("Support subelements must be strings") + links.append((source, elements)) + prepared["Support"] = links + if "AttachmentOffset" in prepared: + offset = prepared["AttachmentOffset"] + if not isinstance(offset, dict): + raise ValueError("AttachmentOffset requires position and quaternion") + position, quaternion = offset.get("position"), offset.get("quaternion") + for values, size in ((position, 3), (quaternion, 4)): + if (not isinstance(values, list) or len(values) != size or + any(isinstance(v, bool) or not isinstance(v, (int, float)) or + not math.isfinite(v) for v in values)): + raise ValueError("AttachmentOffset requires finite position[3] and quaternion[4]") + if not any(quaternion): + raise ValueError("AttachmentOffset quaternion must be nonzero") + prepared["AttachmentOffset"] = App.Placement(App.Vector(*position), App.Rotation(*quaternion)) + # Never commit an unrelated editor's pending transaction. + pending = getattr(doc, "HasPendingTransaction", None) + if pending is None: + pending = getattr(doc, "hasPendingTransaction", None) + if callable(pending): + pending = pending() + if pending is None: + raise RuntimeError("Native document transaction ownership inspection is unavailable") + if pending: + raise ValueError("Finish the current document transaction before atomic attachment apply") + doc.openTransaction("Edit attachment") + try: + for property_name in ("Support", "MapMode", "MapReversed", "AttachmentOffset"): + if property_name in prepared: + setattr(obj, property_name, prepared[property_name]) + if doc.recompute() is False or "Invalid" in obj.State or "Error" in obj.State: + raise RuntimeError("FreeCAD rejected attachment recompute") + value = snapshot() + doc.commitTransaction() + return value + except Exception: + doc.abortTransaction() + doc.recompute() + raise + +try: + _reply = json.dumps({"ok": True, "result": _attachment_command(json.loads(_request))}, allow_nan=False) +except Exception as exc: + _reply = json.dumps({"ok": False, "error": str(exc)}) +)PY", Py_file_input, globals, globals) : nullptr; + bool success = false; + if (executed) { + PyObject* reply = PyDict_GetItemString(globals, "_reply"); + const char* encoded = reply && PyUnicode_Check(reply) ? PyUnicode_AsUTF8(reply) : nullptr; + if (encoded) { + const QJsonObject payload = QJsonDocument::fromJson(QByteArray(encoded)).object(); + success = payload.value(QStringLiteral("ok")).toBool(false); + if (success) { + result = payload.value(QStringLiteral("result")).toObject(); + result.insert(QStringLiteral("command"), command); + } + else { + error = payload.value(QStringLiteral("error")).toString(); + } + } + } + if (!success && error.isEmpty()) { + error = QStringLiteral("FreeCAD attachment Python execution failed"); + } + Py_XDECREF(executed); + Py_XDECREF(globals); + if (PyErr_Occurred()) PyErr_Clear(); + PyGILState_Release(gil); + return success; + } const CommandSpec* spec = findCommand(command); if (!spec) { error = QStringLiteral("Part desktop command is not registered"); @@ -301,6 +510,618 @@ bool executePartDesktopCommand( result.insert(QStringLiteral("command"), command); result.insert(QStringLiteral("input"), args); try { + if (command == QLatin1String("Part_CompoundFilter")) { + std::vector shapes; + if (!readShapes(args, shapes, error)) { + return false; + } + if (shapes.empty() || shapes.size() > 2) { + error = QStringLiteral("CompoundFilter requires a base BREP and an optional stencil BREP"); + return false; + } + const QString mode = args.value(QStringLiteral("FilterType")).toString(); + const bool collision = mode == QLatin1String("collision-pass"); + const bool distance = mode == QLatin1String("window-distance"); + const bool volume = mode == QLatin1String("window-volume"); + const bool area = mode == QLatin1String("window-area"); + const bool length = mode == QLatin1String("window-length"); + const bool bypass = mode == QLatin1String("bypass"); + if (!collision && !distance && !volume && !area && !length && !bypass) { + error = QStringLiteral("Explicit FilterType required; specific items remains implementation_required"); + return false; + } + if ((collision || distance) && shapes.size() != 2) { + error = QStringLiteral("This filter requires a stencil"); + return false; + } + const QJsonValue invertValue = args.value(QStringLiteral("Invert")); + if (!invertValue.isUndefined() && !invertValue.isBool()) { + error = QStringLiteral("Invert must be a boolean"); + return false; + } + const bool invert = invertValue.toBool(false); + const auto measure = [&](const TopoDS_Shape& shape) { + if (collision || distance) { + BRepExtrema_DistShapeShape extrema(shape, shapes[1]); + if (!extrema.IsDone()) { + return std::numeric_limits::quiet_NaN(); + } + return extrema.Value(); + } + GProp_GProps properties; + if (volume) BRepGProp::VolumeProperties(shape, properties); + else if (area) BRepGProp::SurfaceProperties(shape, properties); + else BRepGProp::LinearProperties(shape, properties); + return properties.Mass(); + }; + std::vector children; + std::vector values; + double maximum = -std::numeric_limits::infinity(); + for (TopoDS_Iterator child(shapes.front()); child.More(); child.Next()) { + children.push_back(child.Value()); + const double value = bypass ? 0.0 : measure(child.Value()); + if (!std::isfinite(value)) { + error = QStringLiteral("OpenCascade could not measure a base child"); + return false; + } + values.push_back(value); + if (value > maximum) maximum = value; + } + if (children.empty()) { + error = QStringLiteral("CompoundFilter base has no direct children"); + return false; + } + double lower = 0.0; + double upper = 0.0; + if (!collision && !bypass) { + const auto number = [&](const char* key) { + const QJsonValue value = args.value(QLatin1String(key)); + return value.isDouble() ? value.toDouble() + : std::numeric_limits::quiet_NaN(); + }; + const double from = number("WindowFrom"); + const double to = number("WindowTo"); + const double overrideMaximum = args.contains(QStringLiteral("OverrideMaxVal")) + ? number("OverrideMaxVal") : 0.0; + if (shapes.size() == 2 && !distance) maximum = measure(shapes[1]); + if (overrideMaximum != 0.0) maximum = overrideMaximum; + if (!std::isfinite(from) || !std::isfinite(to) || !std::isfinite(maximum)) { + error = QStringLiteral("WindowFrom, WindowTo and OverrideMaxVal must be finite numbers"); + return false; + } + lower = from / 100.0 * maximum; + upper = to / 100.0 * maximum; + if (!std::isfinite(lower) || !std::isfinite(upper)) { + error = QStringLiteral("Filter window exceeds finite numeric range"); + return false; + } + } + BRep_Builder builder; + TopoDS_Compound compound; + builder.MakeCompound(compound); + TopoDS_Shape single; + QJsonArray indices; + for (std::size_t i = 0; i < children.size(); ++i) { + const bool passes = collision ? values[i] < Precision::Confusion() + : values[i] >= lower && values[i] <= upper; + if (bypass || (passes != invert)) { + single = children[i]; + builder.Add(compound, single); + indices.append(static_cast(i)); + } + } + if (indices.isEmpty()) { + error = QStringLiteral("Nothing passes through the filter"); + return false; + } + publishShape(indices.size() == 1 ? single : TopoDS_Shape(compound), result); + result.insert(QStringLiteral("selectedChildIndices"), indices); + result.insert(QStringLiteral("documentOperation"), QStringLiteral("compound-filter")); + return true; + } + if (command == QLatin1String("Part_ToleranceSet")) { + const auto tolerance = [&args](const char* name, double fallback) { + const QJsonValue value = args.value(QLatin1String(name)); + return value.isUndefined() ? fallback : value.isDouble() + ? value.toDouble() : std::numeric_limits::quiet_NaN(); + }; + const double minimum = tolerance("minTolerance", 1e-7); + const double maximum = tolerance("maxTolerance", 0.0); + if (!std::isfinite(minimum) || !std::isfinite(maximum) + || minimum < 0.0 || maximum < 0.0) { + error = QStringLiteral("minTolerance and maxTolerance must be finite nonnegative numbers"); + return false; + } + const QJsonValue refine = args.value(QStringLiteral("Refine")); + if (!refine.isUndefined() && !refine.isBool()) { + error = QStringLiteral("Refine must be a boolean"); + return false; + } + if (refine.isUndefined() || refine.toBool()) { + error = QStringLiteral("Part_ToleranceSet requires explicit Refine=false until the FreeCAD fix/removeSplitter/fix contract is connected"); + return false; + } + std::vector shapes; + if (!readShapes(args, shapes, error)) { + return false; + } + if (shapes.empty()) { + error = QStringLiteral("Part_ToleranceSet requires at least one selected BREP shape"); + return false; + } + // FeatureToleranceSet.execute copies geometry before limiting its + // vertex, edge and face tolerances; zero maximum is OCCT's sentinel. + BRep_Builder builder; + TopoDS_Compound compound; + builder.MakeCompound(compound); + TopoDS_Shape single; + for (const TopoDS_Shape& shape : shapes) { + BRepBuilderAPI_Copy copy(shape, true, false); + if (!copy.IsDone() || copy.Shape().IsNull()) { + error = QStringLiteral("OpenCascade could not copy the selected shape"); + return false; + } + single = copy.Shape(); + ShapeFix_ShapeTolerance limiter; + limiter.LimitTolerance(single, minimum, maximum, TopAbs_SHAPE); + builder.Add(compound, single); + } + publishShape(shapes.size() == 1 ? single : TopoDS_Shape(compound), result); + result.insert(QStringLiteral("documentOperation"), QStringLiteral("tolerance-set")); + return true; + } + if (command == QLatin1String("Part_Tube")) { + // BasicShapes.Shapes.TubeFeature.execute and makeTube geometry. + // No implemented marker until document properties, attachment and + // the default edit panel are connected to this geometry operation. + const auto parameter = [&args](const char* name, double fallback) { + const QJsonValue value = args.value(QLatin1String(name)); + return value.isUndefined() ? fallback + : value.isDouble() ? value.toDouble() + : std::numeric_limits::quiet_NaN(); + }; + const double outerRadius = parameter("OuterRadius", 5.0); + const double innerRadius = parameter("InnerRadius", 2.0); + const double height = parameter("Height", 10.0); + if (!std::isfinite(outerRadius) || !std::isfinite(innerRadius) + || !std::isfinite(height) || outerRadius <= 0.0 + || innerRadius < 0.0 || height <= 0.0) { + error = QStringLiteral("OuterRadius and Height must be positive finite numbers; InnerRadius must be finite and nonnegative"); + return false; + } + if (innerRadius >= outerRadius) { + error = QStringLiteral("Inner radius must be smaller than outer radius"); + return false; + } + TopoDS_Shape tube = BRepPrimAPI_MakeCylinder(outerRadius, height).Shape(); + if (innerRadius > 0.0) { + const TopoDS_Shape bore = BRepPrimAPI_MakeCylinder(innerRadius, height).Shape(); + BRepAlgoAPI_Cut cut(tube, bore); + if (!cut.IsDone() || cut.Shape().IsNull()) { + error = QStringLiteral("OpenCascade could not cut the tube bore"); + return false; + } + tube = cut.Shape(); + } + if (!BRepCheck_Analyzer(tube).IsValid()) { + error = QStringLiteral("OpenCascade produced an invalid tube"); + return false; + } + publishShape(tube, result); + result.insert(QStringLiteral("documentOperation"), QStringLiteral("create-tube")); + return true; + } + if (command == QLatin1String("Part_ExplodeCompound")) { + std::vector shapes; + if (!readShapes(args, shapes, error)) { + return false; + } + if (shapes.size() != 1) { + error = QStringLiteral("Part_ExplodeCompound requires exactly one BREP shape"); + return false; + } + // Explode.explodeCompound selects direct children, not recursively + // collected solids. Preserve their placement in the supplied BREP. + QJsonArray parts; + for (TopoDS_Iterator child(shapes.front()); child.More(); child.Next()) { + QJsonObject item; + publishShape(child.Value(), item); + item.insert(QStringLiteral("childIndex"), parts.size()); + parts.append(item); + } + result.insert(QStringLiteral("parts"), parts); + result.insert(QStringLiteral("partCount"), parts.size()); + result.insert(QStringLiteral("documentOperation"), QStringLiteral("explode-compound")); + return true; + } + if (command == QLatin1String("Part_JoinConnect")) { + std::vector inputs; + if (!readShapes(args, inputs, error)) { + return false; + } + const QJsonValue value = args.value(QStringLiteral("tolerance")); + const double tolerance = value.toDouble(0.0); + if ((!value.isUndefined() && !value.isDouble()) + || !std::isfinite(tolerance) || tolerance < 0.0) { + error = QStringLiteral("Connect tolerance must be finite and nonnegative"); + return false; + } + // JoinAPI.compoundLeaves recursively expands compounds before generalFuse. + std::vector sources; + const auto appendLeaves = [&](const auto& self, const TopoDS_Shape& shape) -> bool { + if (shape.ShapeType() == TopAbs_COMPOUND) { + for (TopoDS_Iterator child(shape); child.More(); child.Next()) { + if (!self(self, child.Value())) return false; + } + return true; + } + if (shape.ShapeType() != TopAbs_SOLID) return false; + sources.push_back(shape); + return true; + }; + for (const auto& input : inputs) { + if (!appendLeaves(appendLeaves, input)) { + error = QStringLiteral("Connect currently requires solid BREP leaves; shells, wires and compsolids remain implementation_required"); + return false; + } + } + if (sources.empty()) { + error = QStringLiteral("Connect requires at least one nonempty solid selection"); + return false; + } + TopoDS_Compound output; + BRep_Builder compoundBuilder; + compoundBuilder.MakeCompound(output); + if (sources.size() == 1) { + compoundBuilder.Add(output, sources.front()); + } + else { + BOPAlgo_Builder split; + split.SetNonDestructive(true); + split.SetFuzzyValue(tolerance); + for (const auto& source : sources) split.AddArgument(source); + split.Perform(); + if (split.HasErrors()) { + error = QStringLiteral("Connect generalFuse failed"); + return false; + } + struct Piece { + TopoDS_Shape shape; + std::vector owners; + }; + std::vector pieces; + for (TopExp_Explorer it(split.Shape(), TopAbs_SOLID); it.More(); it.Next()) { + bool duplicate = false; + for (const auto& piece : pieces) duplicate |= piece.shape.IsSame(it.Current()); + if (!duplicate) pieces.push_back({it.Current(), {}}); + } + for (std::size_t source = 0; source < sources.size(); ++source) { + const auto& modified = split.Modified(sources[source]); + for (auto& piece : pieces) { + bool belongs = piece.shape.IsSame(sources[source]); + for (TopTools_ListIteratorOfListOfShape it(modified); it.More(); it.Next()) { + belongs |= piece.shape.IsSame(it.Value()); + for (TopExp_Explorer solid(it.Value(), TopAbs_SOLID); solid.More(); solid.Next()) { + belongs |= piece.shape.IsSame(solid.Current()); + } + } + if (belongs) piece.owners.push_back(source); + } + } + for (const auto& piece : pieces) { + if (piece.owners.empty()) { + error = QStringLiteral("Connect could not resolve generalFuse source membership"); + return false; + } + } + std::vector keepers; + for (std::size_t source = 0; source < sources.size(); ++source) { + double maximum = -1.0; + int count = 0; + TopoDS_Shape largest; + for (const auto& piece : pieces) { + if (piece.owners.size() != 1 || piece.owners.front() != source) continue; + GProp_GProps properties; + BRepGProp::VolumeProperties(piece.shape, properties); + const double mass = std::abs(properties.Mass()); + if (!std::isfinite(mass)) { + error = QStringLiteral("Connect fragment has nonfinite volume"); + return false; + } + if (mass > maximum * (1.0 + 1e-8)) { + maximum = mass; + count = 1; + largest = piece.shape; + } + else if (mass >= maximum * (1.0 - 1e-8)) ++count; + } + if (count > 1) { + error = QStringLiteral("There is more than one largest piece!"); + return false; + } + if (!largest.IsNull()) keepers.push_back(largest); + } + auto frontier = keepers; + for (std::size_t overlap = 2; overlap <= sources.size(); ++overlap) { + std::vector next; + for (const auto& piece : pieces) { + if (piece.owners.size() != overlap) continue; + bool connected = false; + for (const auto& previous : frontier) { + for (TopExp_Explorer a(piece.shape, TopAbs_FACE); a.More(); a.Next()) { + for (TopExp_Explorer b(previous, TopAbs_FACE); b.More(); b.Next()) { + connected |= a.Current().IsSame(b.Current()); + } + } + } + if (connected) next.push_back(piece.shape); + } + if (next.empty()) break; + keepers.insert(keepers.end(), next.begin(), next.end()); + frontier = next; + } + if (!keepers.empty()) { + TopoDS_Shape merged = keepers.front(); + for (std::size_t i = 1; i < keepers.size(); ++i) { + BRepAlgoAPI_Fuse fuse(merged, keepers[i]); + if (!fuse.IsDone() || fuse.Shape().IsNull()) { + error = QStringLiteral("Connect could not merge retained fragments"); + return false; + } + merged = fuse.Shape(); + } + for (TopExp_Explorer solid(merged, TopAbs_SOLID); solid.More(); solid.Next()) { + compoundBuilder.Add(output, solid.Current()); + } + } + } + publishShape(output, result); + result.insert(QStringLiteral("documentOperation"), QStringLiteral("join-connect")); + return true; + } + if (command == QLatin1String("Part_JoinCutout") + || command == QLatin1String("Part_JoinEmbed") + || command == QLatin1String("Part_Slice") + || command == QLatin1String("Part_SliceApart")) { + std::vector shapes; + if (!readShapes(args, shapes, error)) { + return false; + } + const bool sliceApart = command == QLatin1String("Part_SliceApart"); + const bool slice = command == QLatin1String("Part_Slice") || sliceApart; + if ((slice && shapes.size() < 2) || (!slice && shapes.size() != 2)) { + error = slice + ? QStringLiteral("Part_Slice requires a base shape followed by at least one cutting tool") + : QStringLiteral("Join requires exactly two shapes in base, tool order"); + return false; + } + if (shapes.front().ShapeType() != TopAbs_SOLID + || (!slice && shapes[1].ShapeType() != TopAbs_SOLID)) { + error = QStringLiteral("This command currently supports solid bases and solid Join tools; aggregate, shell and wire Join semantics remain implementation_required"); + return false; + } + const QJsonValue toleranceValue = args.value(QStringLiteral("tolerance")); + if (!toleranceValue.isUndefined() && !toleranceValue.isDouble()) { + error = QStringLiteral("tolerance must be a finite nonnegative number"); + return false; + } + const double tolerance = toleranceValue.toDouble(0.0); + if (!std::isfinite(tolerance) || tolerance < 0.0) { + error = QStringLiteral("tolerance must be a finite nonnegative number"); + return false; + } + TopoDS_Shape output; + if (slice) { + if (args.contains(QStringLiteral("mode")) + && args.value(QStringLiteral("mode")).toString() != QLatin1String("Split")) { + error = QStringLiteral("Part_Slice desktop command requires mode Split"); + return false; + } + TopTools_ListOfShape bases; + TopTools_ListOfShape tools; + bases.Append(shapes.front()); + for (std::size_t i = 1; i < shapes.size(); ++i) { + tools.Append(shapes[i]); + } + // Split only the base: tool fragments must not enter the result. + BRepAlgoAPI_Splitter splitter; + splitter.SetArguments(bases); + splitter.SetTools(tools); + splitter.SetNonDestructive(true); + splitter.SetFuzzyValue(tolerance); + splitter.Build(); + if (!splitter.IsDone() || splitter.Shape().IsNull()) { + error = QStringLiteral("OpenCascade could not slice the supplied base with the supplied tools"); + return false; + } + QJsonArray parts; + TopoDS_Shape onlyPart; + for (TopExp_Explorer piece(splitter.Shape(), TopAbs_SOLID); piece.More(); piece.Next()) { + QJsonObject item; + onlyPart = piece.Current(); + publishShape(onlyPart, item); + parts.append(item); + } + if (parts.isEmpty()) { + error = QStringLiteral("Slice produced no solid pieces"); + return false; + } + output = parts.size() == 1 ? onlyPart : splitter.Shape(); + result.insert(QStringLiteral("parts"), parts); + result.insert(QStringLiteral("partCount"), parts.size()); + result.insert(QStringLiteral("documentOperation"), sliceApart + ? QStringLiteral("slice-apart") : QStringLiteral("slice")); + } + else { + // JoinAPI's legacy Cutout/Embed ignore tolerance; do not silently + // apply fuzzy booleans with different semantics. + if (tolerance != 0.0) { + error = QStringLiteral("Join Cutout/Embed currently require tolerance 0, matching the legacy geometry handler"); + return false; + } + BRepAlgoAPI_Cut cut(shapes.front(), shapes[1]); + if (!cut.IsDone() || cut.Shape().IsNull()) { + error = QStringLiteral("OpenCascade Join base-minus-tool operation failed"); + return false; + } + double largestVolume = -1.0; + for (TopExp_Explorer piece(cut.Shape(), TopAbs_SOLID); piece.More(); piece.Next()) { + GProp_GProps properties; + BRepGProp::VolumeProperties(piece.Current(), properties); + const double volume = std::abs(properties.Mass()); + if (std::isfinite(volume) && volume > largestVolume) { + largestVolume = volume; + output = piece.Current(); + } + } + if (output.IsNull()) { + error = QStringLiteral("Join cut leaves no solid base fragment"); + return false; + } + if (command == QLatin1String("Part_JoinEmbed")) { + BRepAlgoAPI_Fuse embed(output, shapes[1]); + if (!embed.IsDone() || embed.Shape().IsNull()) { + error = QStringLiteral("OpenCascade could not fuse the largest base fragment with the embedding tool"); + return false; + } + output = embed.Shape(); + result.insert(QStringLiteral("documentOperation"), QStringLiteral("join-embed")); + } + else { + result.insert(QStringLiteral("documentOperation"), QStringLiteral("join-cutout")); + } + } + publishShape(output, result); + return true; + } + if (command == QLatin1String("Part_XOR")) { + const double tolerance = args.value(QStringLiteral("tolerance")).toDouble( + args.contains(QStringLiteral("tolerance")) + ? std::numeric_limits::quiet_NaN() : 0.0); + if (!std::isfinite(tolerance) || tolerance < 0.0) { + error = QStringLiteral("XOR tolerance must be finite and nonnegative"); + return false; + } + std::vector shapes; + if (!readShapes(args, shapes, error)) { + return false; + } + if (shapes.size() == 1 && shapes.front().ShapeType() == TopAbs_COMPOUND) { + const TopoDS_Shape compound = shapes.front(); + shapes.clear(); + for (TopoDS_Iterator child(compound); child.More(); child.Next()) { + shapes.push_back(child.Value()); + } + } + if (shapes.size() < 2) { + error = QStringLiteral("XOR requires two shapes or a compound containing two shapes"); + return false; + } + std::vector pending = shapes; + while (!pending.empty()) { + const TopoDS_Shape shape = pending.back(); + pending.pop_back(); + if (shape.ShapeType() == TopAbs_SOLID) { + continue; + } + if (shape.ShapeType() != TopAbs_COMPOUND && shape.ShapeType() != TopAbs_COMPSOLID) { + error = QStringLiteral("XOR currently requires solids or aggregates of solids; surface and wire XOR is not implemented"); + return false; + } + for (TopoDS_Iterator child(shape); child.More(); child.Next()) { + pending.push_back(child.Value()); + } + } + BOPAlgo_Builder fragments; + fragments.SetNonDestructive(true); + fragments.SetFuzzyValue(tolerance); + for (const TopoDS_Shape& shape : shapes) { + fragments.AddArgument(shape); + } + fragments.Perform(); + if (fragments.HasErrors() || fragments.Shape().IsNull()) { + error = QStringLiteral("XOR general fuse failed for the supplied shapes"); + return false; + } + TopoDS_Compound output; + BRep_Builder builder; + builder.MakeCompound(output); + // SplitAPI.xor keeps general-fuse pieces with odd source membership. + // Volume overlap determines membership for the supported solid domain. + for (TopExp_Explorer piece(fragments.Shape(), TopAbs_SOLID); piece.More(); piece.Next()) { + GProp_GProps properties; + BRepGProp::VolumeProperties(piece.Current(), properties); + const double volume = std::abs(properties.Mass()); + if (!std::isfinite(volume) || volume <= 0.0) { + error = QStringLiteral("XOR produced a solid without positive finite volume"); + return false; + } + unsigned sources = 0; + for (const TopoDS_Shape& source : shapes) { + BRepAlgoAPI_Common overlap(piece.Current(), source); + if (!overlap.IsDone() || overlap.Shape().IsNull()) { + error = QStringLiteral("XOR source membership calculation failed"); + return false; + } + GProp_GProps commonProperties; + BRepGProp::VolumeProperties(overlap.Shape(), commonProperties); + if (std::abs(commonProperties.Mass()) > volume * 0.5) { + ++sources; + } + } + if (sources % 2 == 1) { + builder.Add(output, piece.Current()); + } + } + result.insert(QStringLiteral("documentOperation"), QStringLiteral("boolean")); + publishShape(output, result); + return true; + } + if (command == QLatin1String("Part_BooleanFragments")) { + // SplitFeatures activates Standard mode; preserve general-fuse aggregates. + const QString mode = args.value(QStringLiteral("mode")).toString(QStringLiteral("Standard")); + if (mode != QLatin1String("Standard")) { + error = QStringLiteral("BooleanFragments currently supports Standard mode only"); + return false; + } + const double tolerance = args.value(QStringLiteral("tolerance")).toDouble( + args.contains(QStringLiteral("tolerance")) + ? std::numeric_limits::quiet_NaN() : 0.0); + if (!std::isfinite(tolerance) || tolerance < 0.0) { + error = QStringLiteral("tolerance must be a finite nonnegative number"); + return false; + } + std::vector shapes; + if (!readShapes(args, shapes, error)) { + return false; + } + if (shapes.size() == 1 && shapes.front().ShapeType() == TopAbs_COMPOUND) { + const TopoDS_Shape compound = shapes.front(); + shapes.clear(); + for (TopoDS_Iterator child(compound); child.More(); child.Next()) { + shapes.push_back(child.Value()); + } + } + if (shapes.size() < 2) { + error = QStringLiteral("BooleanFragments requires at least two shapes or a compound containing at least two shapes"); + return false; + } + BOPAlgo_Builder fragments; + fragments.SetNonDestructive(true); + fragments.SetFuzzyValue(tolerance); + for (const TopoDS_Shape& shape : shapes) { + fragments.AddArgument(shape); + } + fragments.Perform(); + if (fragments.HasErrors() || fragments.Shape().IsNull()) { + error = QStringLiteral("BooleanFragments general fuse failed for the supplied shapes"); + return false; + } + result.insert(QStringLiteral("documentOperation"), QStringLiteral("boolean")); + result.insert(QStringLiteral("mode"), mode); + publishShape(fragments.Shape(), result); + return true; + } if (spec->operation == Operation::BoxSelection) { const QJsonArray selection = args.value(QStringLiteral("selection")).toArray(); result.insert(QStringLiteral("documentOperation"), QStringLiteral("box-selection")); diff --git a/src/WebAssembly/FreeCADCoreProbe/SketcherDesktopCommands.cpp b/src/WebAssembly/FreeCADCoreProbe/SketcherDesktopCommands.cpp new file mode 100644 index 00000000..980a4a48 --- /dev/null +++ b/src/WebAssembly/FreeCADCoreProbe/SketcherDesktopCommands.cpp @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#include +#include "SketcherDesktopCommands.h" +#include +#include + +#ifndef FC_WEB_IMPLEMENTED_DESKTOP_COMMAND +#define FC_WEB_IMPLEMENTED_DESKTOP_COMMAND(name) +#endif +FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("Sketcher_ProfilesHexagon1") + +static bool executeHexagonProfileCommand(const QJsonObject& args, + QJsonObject& output, std::string& error) +{ + PyObject* globals = PyDict_New(); + const QByteArray encoded = QJsonDocument(args).toJson(QJsonDocument::Compact); + PyObject* input = PyUnicode_FromStringAndSize(encoded.constData(), encoded.size()); + if (!globals || !input) { + Py_XDECREF(globals); + Py_XDECREF(input); + PyErr_Clear(); + error = "Unable to allocate Sketcher command arguments"; + return false; + } + PyDict_SetItemString(globals, "__builtins__", PyEval_GetBuiltins()); + PyDict_SetItemString(globals, "request_json", input); + Py_DECREF(input); + // Execute the packaged official profile function without importing desktop GUI modules. + const char* script = R"PY( +import ast, json +import FreeCAD as App, Part, Sketcher +a = json.loads(request_json) +d = App.ActiveDocument +if d is None: + raise ValueError('An active document is required') +if 'documentName' in a and a['documentName'] != d.Name: + raise ValueError('Requested document is not the active document') +name = a.get('sketchName') +if name is not None and (not isinstance(name, str) or not name): + raise ValueError('sketchName must be a nonempty object name') +s = d.getObject(name) if name else None +if name and (s is None or not s.isDerivedFrom('Sketcher::SketchObject')): + raise ValueError('Requested object is not a sketch in the active document') +path = '/freecad-mod/Sketcher/ProfileLib/Hexagon.py' +with open(path) as source: + tree = ast.parse(source.read(), path) +tree.body = [node for node in tree.body if isinstance(node, ast.FunctionDef) and node.name == 'makeHexagonSimple'] +if len(tree.body) != 1: + raise RuntimeError('Official hexagon profile function is unavailable') +env = {'App': App, 'FreeCAD': App, 'Part': Part, 'Sketcher': Sketcher} +exec(compile(tree, path, 'exec'), env) +before = {obj.Name for obj in d.Objects} +d.openTransaction('Create hexagon profile') +try: + env['makeHexagonSimple'](name) + if s is None: + created = [obj for obj in d.Objects if obj.Name not in before and obj.isDerivedFrom('Sketcher::SketchObject')] + if len(created) != 1: + raise RuntimeError('Profile did not create a unique sketch') + s = created[0] + solve_status = s.solve() + if solve_status != 0: + raise RuntimeError('Hexagon profile solver failed: %s' % solve_status) + d.recompute() + result_json = json.dumps({'documentName': d.Name, 'objectName': s.Name, + 'objectType': s.TypeId, 'geometryCount': s.GeometryCount, + 'constraintCount': s.ConstraintCount, 'brep': s.Shape.exportBrepToString(), + 'edges': [[[p.x, p.y, p.z] for p in edge.discretize(Deflection=0.1)] for edge in s.Shape.Edges]}) + d.commitTransaction() +except BaseException: + d.abortTransaction() + raise +)PY"; + PyObject* execution = PyRun_String(script, Py_file_input, globals, globals); + if (!execution) { + 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 ? text : "Sketcher desktop command failed"; + Py_XDECREF(message); + Py_XDECREF(type); + Py_XDECREF(value); + Py_XDECREF(traceback); + PyErr_Clear(); + Py_DECREF(globals); + return false; + } + Py_DECREF(execution); + PyObject* result = PyDict_GetItemString(globals, "result_json"); + const char* text = result ? PyUnicode_AsUTF8(result) : nullptr; + QJsonParseError parseError; + const QJsonDocument decoded = QJsonDocument::fromJson(text ? QByteArray(text) : QByteArray(), &parseError); + const bool ok = parseError.error == QJsonParseError::NoError && decoded.isObject(); + if (!ok) { + error = "Invalid native Sketcher command result"; + Py_DECREF(globals); + return false; + } + output = decoded.object(); + output.insert(QStringLiteral("documentHandle"), output.value(QStringLiteral("documentName"))); + output.insert(QStringLiteral("objectHandle"), output.value(QStringLiteral("objectName"))); + output.insert(QStringLiteral("recomputed"), true); + Py_DECREF(globals); + return ok; +} + +bool executeSketcherDesktopCommand(const QString& command, const QJsonObject& args, + QJsonObject& result, std::string& error) +{ + if (command == QStringLiteral("Sketcher_ProfilesHexagon1")) { + if (!executeHexagonProfileCommand(args, result, error)) + return false; + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + result.insert(QStringLiteral("command"), command); + return true; + } + error = "Unsupported Sketcher desktop command"; + return false; +} diff --git a/src/WebAssembly/FreeCADCoreProbe/SketcherDesktopCommands.h b/src/WebAssembly/FreeCADCoreProbe/SketcherDesktopCommands.h new file mode 100644 index 00000000..2f27228d --- /dev/null +++ b/src/WebAssembly/FreeCADCoreProbe/SketcherDesktopCommands.h @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#pragma once +#include +#include +#include + +bool executeSketcherDesktopCommand(const QString& command, const QJsonObject& args, + QJsonObject& output, std::string& error); diff --git a/src/WebAssembly/FreeCADCoreProbe/SketcherProbe.cpp b/src/WebAssembly/FreeCADCoreProbe/SketcherProbe.cpp index 980bdc9c..e47c28ca 100644 --- a/src/WebAssembly/FreeCADCoreProbe/SketcherProbe.cpp +++ b/src/WebAssembly/FreeCADCoreProbe/SketcherProbe.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: LGPL-2.1-or-later #include +#include "SketcherDesktopCommands.h" #include #include #include @@ -873,6 +874,32 @@ QJsonObject probePythonFamilyRecord(const QJsonObject& input) return result; } + if (apiId.startsWith(QStringLiteral("Sketcher:desktop:"))) { + const QString command = apiId.mid(QStringLiteral("Sketcher:desktop:").size()); + if (command == QStringLiteral("Sketcher_ProfilesHexagon1")) { + if (name != command || !input.value(QStringLiteral("args")).isObject()) { + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("invalid_request")); + result.insert(QStringLiteral("message"), QStringLiteral("Hexagon requires the matching command name and an args object")); + return result; + } + QJsonObject value; + std::string commandError; + if (!executeSketcherDesktopCommand(command, input.value(QStringLiteral("args")).toObject(), value, commandError)) { + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_command_failed")); + result.insert(QStringLiteral("message"), QString::fromStdString(commandError)); + result.insert(QStringLiteral("resourcesReleased"), App::GetApplication().getDocuments().empty()); + return result; + } + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + result.insert(QStringLiteral("command"), command); + result.insert(QStringLiteral("result"), value); + result.insert(QStringLiteral("resourcesReleased"), App::GetApplication().getDocuments().empty()); + return result; + } + } + if (family == QStringLiteral("desktop-command")) { const QStringList commands = { QStringLiteral("Constraints"), QStringLiteral("Geometries"), QStringLiteral("Sketcher"), diff --git a/src/WebAssembly/FreeCADCoreProbe/TechDrawDesktopCommands.cpp b/src/WebAssembly/FreeCADCoreProbe/TechDrawDesktopCommands.cpp new file mode 100644 index 00000000..a8b944c2 --- /dev/null +++ b/src/WebAssembly/FreeCADCoreProbe/TechDrawDesktopCommands.cpp @@ -0,0 +1,3539 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#include "TechDrawDesktopCommands.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_LengthDimension") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_HorizontalDimension") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_VerticalDimension") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_RadiusDimension") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_DiameterDimension") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_AngleDimension") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_3PtAngleDimension") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_AreaDimension") +bool dispatchDimensions(const QString& name, const QJsonObject& input, QJsonObject& result) +{ + if (name == QStringLiteral("TechDraw_CompDimensionTools")) { + QJsonObject args = input.value(QStringLiteral("args")).toObject(); + if (args.value(QStringLiteral("args")).isObject()) + args = args.value(QStringLiteral("args")).toObject(); + result.insert(QStringLiteral("parameterSchema"), QStringLiteral( + "{command:string,document:string,page:string,view:string,selection:string[],x:number,y:number}")); + if (input.value(QStringLiteral("apiId")).toString() != QStringLiteral("TechDraw:desktop:") + name) { + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QStringLiteral("Desktop command ID/name mismatch")); + return true; + } + // Preserve the official group membership; only native child execution can succeed. + const struct { const char* command; bool available; const char* dependency; } tools[] = { + {"TechDraw_Dimension", false, "TDHandlerDimension selection inference and interactive placement"}, + {"TechDraw_LengthDimension", true, ""}, + {"TechDraw_HorizontalDimension", true, ""}, + {"TechDraw_VerticalDimension", true, ""}, + {"TechDraw_RadiusDimension", true, ""}, + {"TechDraw_DiameterDimension", true, ""}, + {"TechDraw_AngleDimension", true, ""}, + {"TechDraw_3PtAngleDimension", true, ""}, + {"TechDraw_AreaDimension", true, ""}, + {"TechDraw_ExtensionCreateLengthArc", false, "Native arc length dimension creation adapter"}, + {"TechDraw_HorizontalExtentDimension", false, "Official execExtent DistanceX geometry and references"}, + {"TechDraw_VerticalExtentDimension", false, "Official execExtent DistanceY geometry and references"}, + {"TechDraw_ExtensionCreateHorizChainDimension", false, "Native horizontal chain dimension creation adapter"}, + {"TechDraw_ExtensionCreateVertChainDimension", false, "Native vertical chain dimension creation adapter"}, + {"TechDraw_ExtensionCreateObliqueChainDimension", false, "Native oblique chain dimension creation adapter"}, + {"TechDraw_ExtensionCreateHorizCoordDimension", false, "Native horizontal coordinate dimension creation adapter"}, + {"TechDraw_ExtensionCreateVertCoordDimension", false, "Native vertical coordinate dimension creation adapter"}, + {"TechDraw_ExtensionCreateObliqueCoordDimension", false, "Native oblique coordinate dimension creation adapter"}, + {"TechDraw_ExtensionCreateHorizChamferDimension", false, "Native horizontal chamfer dimension creation adapter"}, + {"TechDraw_ExtensionCreateVertChamferDimension", false, "Native vertical chamfer dimension creation adapter"} + }; + const auto selected = args.value(QStringLiteral("command")).toString(); + QJsonArray choices; + for (const auto& tool : tools) choices.append(QString::fromLatin1(tool.command)); + result.insert(QStringLiteral("commands"), choices); + for (const auto& tool : tools) { + if (selected != QString::fromLatin1(tool.command)) continue; + if (!tool.available) { + result.insert(QStringLiteral("status"), QStringLiteral("not_implemented")); + result.insert(QStringLiteral("code"), QStringLiteral("implementation_required")); + result.insert(QStringLiteral("message"), QString::fromLatin1(tool.dependency)); + return true; + } + QJsonObject childInput = input; + args.remove(QStringLiteral("command")); + childInput.insert(QStringLiteral("args"), args); + childInput.insert(QStringLiteral("apiId"), QStringLiteral("TechDraw:desktop:") + selected); + QJsonObject childResult; + const bool handled = dispatchDimensions(selected, childInput, childResult); + if (!handled) { + result.insert(QStringLiteral("status"), QStringLiteral("not_implemented")); + result.insert(QStringLiteral("code"), QStringLiteral("implementation_required")); + result.insert(QStringLiteral("message"), QStringLiteral("Native dimension child handler is unavailable")); + return true; + } + for (auto it = childResult.constBegin(); it != childResult.constEnd(); ++it) + if (it.key() != QStringLiteral("parameterSchema")) result.insert(it.key(), it.value()); + result.insert(QStringLiteral("selectedCommand"), selected); + if (childResult.value(QStringLiteral("status")).toString() == QStringLiteral("exposed")) + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + return true; + } + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QStringLiteral("command must name an official dimension group member")); + return true; + } + // FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_DimensionRepair") + if (name == QStringLiteral("TechDraw_DimensionRepair")) { + QJsonObject args = input.value(QStringLiteral("args")).toObject(); + if (args.value(QStringLiteral("args")).isObject()) + args = args.value(QStringLiteral("args")).toObject(); + result.insert(QStringLiteral("parameterSchema"), QStringLiteral( + "{document:string,dimension:string,references2d?:{object:string,subelement:string}[],references3d?:{object:string,subelement:string}[]}")); + App::Document* document = nullptr; + bool transaction = false; + try { + if (input.value(QStringLiteral("apiId")).toString() != QStringLiteral("TechDraw:desktop:") + name) + throw std::runtime_error("Desktop command ID/name mismatch"); + const QString documentName = args.value(QStringLiteral("document")).toString(); + const QString dimensionName = args.value(QStringLiteral("dimension")).toString(); + if (documentName.isEmpty() || dimensionName.isEmpty()) + throw std::runtime_error("Existing document and dimension handles are required"); + document = App::GetApplication().getDocument(documentName.toUtf8().constData()); + if (!document) throw std::runtime_error("Document does not exist"); + auto* dimension = dynamic_cast( + document->getObject(dimensionName.toUtf8().constData())); + if (!dimension) throw std::runtime_error("Selected object is not a dimension"); + const bool replace2d = args.contains(QStringLiteral("references2d")); + const bool replace3d = args.contains(QStringLiteral("references3d")); + if (!replace2d && !replace3d) + throw std::runtime_error("Replacement references2d or references3d are required"); + std::vector objects2d, objects3d; + std::vector subs2d, subs3d; + auto parse = [&](const char* key, bool projected, + std::vector& objects, + std::vector& subs) { + const auto value = args.value(QString::fromLatin1(key)); + if (!value.isArray()) throw std::runtime_error("References must be an array"); + for (const auto& entry : value.toArray()) { + if (!entry.isObject()) throw std::runtime_error("Each reference requires object and subelement"); + const auto ref = entry.toObject(); + const QString objectName = ref.value(QStringLiteral("object")).toString(); + const QString sub = ref.value(QStringLiteral("subelement")).toString(); + auto* object = objectName.isEmpty() ? nullptr : document->getObject(objectName.toUtf8().constData()); + if (!object || object == dimension || sub.isEmpty()) + throw std::runtime_error("Reference requires an existing object and nonempty subelement"); + if (projected && !dynamic_cast(object)) + throw std::runtime_error("2D references require a DrawViewPart"); + objects.push_back(object); + subs.push_back(sub.toStdString()); + } + }; + if (replace2d) parse("references2d", true, objects2d, subs2d); + if (replace3d) parse("references3d", false, objects3d, subs3d); + document->openTransaction("Repair dimension references"); + transaction = true; + if (replace2d) dimension->References2D.setValues(objects2d, subs2d); + if (replace3d) dimension->References3D.setValues(objects3d, subs3d); + dimension->touch(true); + document->recompute(); + if (dimension->isError()) throw std::runtime_error("Dimension recomputation failed for replacement references"); + const double measured = dimension->getDimValue(); + if (!std::isfinite(measured)) throw std::runtime_error("Repaired dimension produced a nonfinite measurement"); + auto serialize = [](const auto& property) { + QJsonArray refs; + const auto& objects = property.getValues(); + const auto& subs = property.getSubValues(); + for (size_t i = 0; i < objects.size(); ++i) { + if (!objects[i] || i >= subs.size()) throw std::runtime_error("Invalid native dimension reference"); + refs.append(QJsonObject{ + {QStringLiteral("object"), QString::fromUtf8(objects[i]->getNameInDocument())}, + {QStringLiteral("subelement"), QString::fromStdString(subs[i])}}); + } + return refs; + }; + QJsonObject state{ + {QStringLiteral("document"), QString::fromUtf8(document->getName())}, + {QStringLiteral("dimension"), QString::fromUtf8(dimension->getNameInDocument())}, + {QStringLiteral("references2d"), serialize(dimension->References2D)}, + {QStringLiteral("references3d"), serialize(dimension->References3D)}, + {QStringLiteral("value"), measured}}; + document->commitTransaction(); + transaction = false; + result.insert(QStringLiteral("result"), state); + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command")); + } catch (const std::exception& error) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } catch (...) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QStringLiteral("Native dimension repair failed")); + } + return true; + } + const struct { const char* command; const char* type; } commands[] = { + {"TechDraw_LengthDimension", "Distance"}, + {"TechDraw_HorizontalDimension", "DistanceX"}, + {"TechDraw_VerticalDimension", "DistanceY"}, + {"TechDraw_RadiusDimension", "Radius"}, + {"TechDraw_DiameterDimension", "Diameter"}, + {"TechDraw_AngleDimension", "Angle"}, + {"TechDraw_3PtAngleDimension", "Angle3Pt"}, + {"TechDraw_AreaDimension", "Area"} + }; + const char* type = nullptr; + for (const auto& command : commands) + if (name == QString::fromLatin1(command.command)) type = command.type; + if (!type) { + const struct { const char* command; const char* dependency; } pending[] = { + {"TechDraw_Dimension", "TDHandlerDimension selection inference and interactive placement"}, + {"TechDraw_ExtentGroup", "Both official execExtent modes and extent reference construction"}, + {"TechDraw_HorizontalExtentDimension", "Official execExtent DistanceX geometry and references"}, + {"TechDraw_VerticalExtentDimension", "Official execExtent DistanceY geometry and references"} + }; + for (const auto& command : pending) { + if (name != QString::fromLatin1(command.command)) continue; + result.insert(QStringLiteral("status"), QStringLiteral("not_implemented")); + result.insert(QStringLiteral("code"), QStringLiteral("implementation_required")); + result.insert(QStringLiteral("message"), QString::fromLatin1(command.dependency)); + return true; + } + return false; + } + QJsonObject args = input.value(QStringLiteral("args")).toObject(); + if (args.value(QStringLiteral("args")).isObject()) args = args.value(QStringLiteral("args")).toObject(); + result.insert(QStringLiteral("parameterSchema"), QStringLiteral( + "{document:string,page:string,view:string,selection:string[],x:number,y:number}")); + App::Document* document = nullptr; + bool transaction = false; + auto fail = [&](const char* message) { + if (transaction) { document->abortTransaction(); transaction = false; } + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(message)); + }; + try { + if (input.value(QStringLiteral("apiId")).toString() != QStringLiteral("TechDraw:desktop:") + name) + throw std::runtime_error("Desktop command ID/name mismatch"); + auto required = [&](const char* key) { + const QString value = args.value(QString::fromLatin1(key)).toString(); + if (value.isEmpty()) throw std::runtime_error("document, page and view handles are required"); + return value; + }; + document = App::GetApplication().getDocument(required("document").toUtf8().constData()); + if (!document) throw std::runtime_error("Document does not exist"); + auto* page = dynamic_cast(document->getObject(required("page").toUtf8().constData())); + auto* view = dynamic_cast(document->getObject(required("view").toUtf8().constData())); + if (!page || !view) throw std::runtime_error("Existing DrawPage and DrawViewPart are required"); + bool onPage = false; + for (auto* member : page->Views.getValues()) if (member == view) onPage = true; + if (!onPage) throw std::runtime_error("Selected view does not belong to the page"); + const auto selection = args.value(QStringLiteral("selection")).toArray(); + const bool areaDimension = name == QStringLiteral("TechDraw_AreaDimension"); + if (areaDimension && selection.size() != 1) + throw std::runtime_error("Area annotation requires exactly one projected face"); + if (selection.isEmpty() || selection.size() > 3) throw std::runtime_error("Select one to three projected edges or vertices"); + std::vector objects; + std::vector subnames; + for (const auto& item : selection) { + const QString sub = item.toString(); + const bool edge = sub.startsWith(QStringLiteral("Edge")); + const bool vertex = sub.startsWith(QStringLiteral("Vertex")); + const bool face = sub.startsWith(QStringLiteral("Face")); + bool validIndex = false; + const int index = sub.mid((edge || face) ? 4 : 6).toInt(&validIndex); + if (!validIndex || index < 1 || (areaDimension ? !face : (!edge && !vertex))) + throw std::runtime_error("Selection requires FaceN for area, or EdgeN/VertexN for other dimensions"); + if (areaDimension && !view->getFace(sub.toStdString())) + throw std::runtime_error("Selected projected face does not exist in the current view"); + if (QString::fromLatin1(type) == QStringLiteral("Angle3Pt") && (!vertex || selection.size() != 3)) + throw std::runtime_error("Three-point angle requires three vertices"); + if ((QString::fromLatin1(type) == QStringLiteral("Radius") || QString::fromLatin1(type) == QStringLiteral("Diameter")) + && (!edge || selection.size() != 1)) throw std::runtime_error("Radius and diameter require one circular edge"); + objects.push_back(view); + subnames.push_back(sub.toStdString()); + } + const auto x = args.value(QStringLiteral("x")); + const auto y = args.value(QStringLiteral("y")); + if (!x.isDouble() || !y.isDouble() || !std::isfinite(x.toDouble()) || !std::isfinite(y.toDouble())) + throw std::runtime_error("Finite dimension text coordinates x and y are required"); + document->openTransaction(name.toUtf8().constData()); + transaction = true; + auto* dimension = document->addObject("Dimension"); + dimension->Type.setValue(type); + dimension->MeasureType.setValue("Projected"); + dimension->References2D.setValues(objects, subnames); + page->addView(dimension); + dimension->X.setValue(x.toDouble()); + dimension->Y.setValue(y.toDouble()); + document->recompute(); + if (dimension->isError()) throw std::runtime_error("Native dimension computation failed for the selected geometry"); + view->touch(true); + QJsonObject state; + state.insert(QStringLiteral("document"), QString::fromUtf8(document->getName())); + state.insert(QStringLiteral("page"), QString::fromUtf8(page->getNameInDocument())); + state.insert(QStringLiteral("view"), QString::fromUtf8(view->getNameInDocument())); + state.insert(QStringLiteral("dimension"), QString::fromUtf8(dimension->getNameInDocument())); + state.insert(QStringLiteral("type"), QString::fromLatin1(dimension->Type.getValueAsString())); + state.insert(QStringLiteral("x"), dimension->X.getValue()); + state.insert(QStringLiteral("y"), dimension->Y.getValue()); + if (areaDimension) { + const double area = dimension->getDimValue(); + if (!std::isfinite(area) || area <= 0.0) + throw std::runtime_error("Native area computation did not produce a positive finite value"); + state.insert(QStringLiteral("area"), area); + state.insert(QStringLiteral("useActualArea"), dimension->UseActualArea.getValue()); + } + QJsonArray references; + for (const auto& sub : dimension->References2D.getSubValues()) references.append(QString::fromStdString(sub)); + state.insert(QStringLiteral("references"), references); + document->commitTransaction(); + transaction = false; + result.insert(QStringLiteral("result"), state); + if (name == QStringLiteral("TechDraw_LengthDimension")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_HorizontalDimension")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_VerticalDimension")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_RadiusDimension")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_DiameterDimension")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_AngleDimension")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_3PtAngleDimension")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_AreaDimension")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command")); + } catch (const Base::Exception& error) { fail(error.what()); } + catch (const std::exception& error) { fail(error.what()); } + return true; +} +// TechDraw_View creates a native projection below, but remains partial until its +// desktop selection/task workflow and mounted renderer are connected. +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_DetailView") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_Symbol") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_SpreadsheetView") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_Balloon") +bool dispatchDocumentViews(const QString& name, const QJsonObject& input, QJsonObject& result) +{ + if (name == QStringLiteral("TechDraw_DetailView")) { + QJsonObject args = input.value(QStringLiteral("args")).toObject(); + if (args.value(QStringLiteral("args")).isObject()) + args = args.value(QStringLiteral("args")).toObject(); + result.insert(QStringLiteral("parameterSchema"), QStringLiteral( + "{documentId:string,document?:string,baseView?:string,selection?:object[],view?:string,action?:\"read\"|\"accept\"|\"cancel\",anchorX:number,anchorY:number,radius:number,scaleType?:0|1|2,scale?:number,x:number,y:number,reference:string}")); + App::Document* document = nullptr; + bool transaction = false; + auto fail = [&](const char* message) { + if (transaction) { document->abortTransaction(); transaction = false; } + PyErr_Clear(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(message)); + }; + try { + if (input.value(QStringLiteral("apiId")).toString() != QStringLiteral("TechDraw:desktop:") + name) + throw std::runtime_error("Desktop command ID/name mismatch"); + const auto actionValue = args.value(QStringLiteral("action")); + if (!actionValue.isUndefined() && !actionValue.isString()) + throw std::runtime_error("action must be read, accept or cancel"); + const QString action = actionValue.toString(QStringLiteral("accept")); + if (action != QStringLiteral("read") && action != QStringLiteral("accept") + && action != QStringLiteral("cancel")) + throw std::runtime_error("action must be read, accept or cancel"); + if (action == QStringLiteral("cancel")) { + // Accept is the only action that opens a document transaction. + result.insert(QStringLiteral("status"), QStringLiteral("cancelled")); + return true; + } + for (const auto& key : {QStringLiteral("taskActive"), QStringLiteral("activeTask")}) { + if (args.contains(key) && !args.value(key).isBool()) + throw std::runtime_error("Task state must be boolean"); + } + if (args.value(QStringLiteral("taskActive")).toBool() + || args.value(QStringLiteral("activeTask")).toBool()) + throw std::runtime_error("Finish the active task first"); + auto text = [&](const char* key) { + const auto value = args.value(QString::fromLatin1(key)); + if (!value.isString() || value.toString().isEmpty()) + throw std::runtime_error(std::string(key) + " must be a nonempty string"); + return value.toString(); + }; + auto number = [&](const char* key) { + const auto value = args.value(QString::fromLatin1(key)); + if (!value.isDouble() || !std::isfinite(value.toDouble())) + throw std::runtime_error(std::string(key) + " must be finite"); + return value.toDouble(); + }; + if (!args.contains(QStringLiteral("document"))) + args.insert(QStringLiteral("document"), args.value(QStringLiteral("documentId"))); + document = App::GetApplication().getDocument(text("document").toUtf8().constData()); + if (!document) throw std::runtime_error("Document does not exist"); + if (args.contains(QStringLiteral("documentId")) + && args.value(QStringLiteral("documentId")) != args.value(QStringLiteral("document"))) + throw std::runtime_error("document must match the current documentId"); + if (!args.contains(QStringLiteral("baseView"))) { + for (const auto& selected : args.value(QStringLiteral("selection")).toArray()) { + QString objectName; + if (selected.isString()) objectName = selected.toString(); + else if (selected.isObject()) { + const auto entry = selected.toObject(); + if (entry.value(QStringLiteral("documentId")).toString() != text("document")) + throw std::runtime_error("Selection belongs to another document"); + objectName = entry.value(QStringLiteral("object")).toString(); + } + if (dynamic_cast(document->getObject(objectName.toUtf8().constData()))) { + args.insert(QStringLiteral("baseView"), objectName); + break; + } + } + } + auto* base = dynamic_cast( + document->getObject(text("baseView").toUtf8().constData())); + if (!base) throw std::runtime_error("Select a current-document DrawViewPart base"); + auto* page = base->findParentPage(); + if (!page || page->getDocument() != document) + throw std::runtime_error("Base view must belong to a current-document page"); + auto renderDetail = [](TechDraw::DrawViewPart* detail) { + if (detail->isError() || detail->getEdgeGeometry().empty()) + throw std::runtime_error("Detail boundary produced no usable native geometry"); + auto decref = [](PyObject* object) { Py_XDECREF(object); }; + using PythonRef = std::unique_ptr; + PythonRef module(PyImport_ImportModule("TechDraw"), decref); + PythonRef renderer(module ? PyObject_GetAttrString(module.get(), "viewPartAsSvg") : nullptr, decref); + PythonRef object(detail->getPyObject(), decref); + PythonRef rendered(renderer && object + ? PyObject_CallFunctionObjArgs(renderer.get(), object.get(), nullptr) : nullptr, decref); + const char* svg = rendered && PyUnicode_Check(rendered.get()) + ? PyUnicode_AsUTF8(rendered.get()) : nullptr; + if (!svg || !*svg) throw std::runtime_error("Native detail SVG rendering failed"); + return QString::fromUtf8(svg); + }; + if (action == QStringLiteral("read")) { + if (!args.contains(QStringLiteral("view"))) { + result.insert(QStringLiteral("result"), QJsonObject { + {QStringLiteral("document"), QString::fromUtf8(document->getName())}, + {QStringLiteral("page"), QString::fromUtf8(page->getNameInDocument())}, + {QStringLiteral("baseView"), QString::fromUtf8(base->getNameInDocument())}, + {QStringLiteral("baseSvg"), renderDetail(base)} + }); + result.insert(QStringLiteral("status"), QStringLiteral("partial")); + result.insert(QStringLiteral("code"), QStringLiteral("implementation_required")); + result.insert(QStringLiteral("message"), QStringLiteral( + "Current base geometry rendered for detail parameters; boundary highlight and page composition remain required.")); + return true; + } + auto* existing = dynamic_cast( + document->getObject(text("view").toUtf8().constData())); + if (!existing || existing->BaseView.getValue() != base + || existing->findParentPage() != page) + throw std::runtime_error("Detail view must belong to the selected base and page"); + result.insert(QStringLiteral("result"), QJsonObject { + {QStringLiteral("document"), QString::fromUtf8(document->getName())}, + {QStringLiteral("page"), QString::fromUtf8(page->getNameInDocument())}, + {QStringLiteral("view"), QString::fromUtf8(existing->getNameInDocument())}, + {QStringLiteral("baseView"), QString::fromUtf8(base->getNameInDocument())}, + {QStringLiteral("anchorX"), existing->AnchorPoint.getValue().x}, + {QStringLiteral("anchorY"), existing->AnchorPoint.getValue().y}, + {QStringLiteral("radius"), existing->Radius.getValue()}, + {QStringLiteral("scaleType"), static_cast(existing->ScaleType.getValue())}, + {QStringLiteral("scale"), existing->Scale.getValue()}, + {QStringLiteral("x"), existing->X.getValue()}, + {QStringLiteral("y"), existing->Y.getValue()}, + {QStringLiteral("reference"), QString::fromUtf8(existing->Reference.getValue())}, + {QStringLiteral("edgeCount"), static_cast(existing->getEdgeGeometry().size())}, + {QStringLiteral("svg"), renderDetail(existing)}, + {QStringLiteral("baseSvg"), renderDetail(base)} + }); + result.insert(QStringLiteral("status"), QStringLiteral("partial")); + result.insert(QStringLiteral("code"), QStringLiteral("implementation_required")); + result.insert(QStringLiteral("message"), QStringLiteral( + "Native detail parameters and SVG read; boundary highlight and page composition remain required.")); + return true; + } + const double anchorX = number("anchorX"), anchorY = number("anchorY"); + const double radius = number("radius"); + const double scaleTypeValue = args.contains(QStringLiteral("scaleType")) + ? number("scaleType") : 2.0; + if (scaleTypeValue < 0 || scaleTypeValue > 2 || std::floor(scaleTypeValue) != scaleTypeValue) + throw std::runtime_error("scaleType must be 0 (page), 1 (automatic) or 2 (custom)"); + const long scaleType = static_cast(scaleTypeValue); + const double scale = scaleType == 2 ? number("scale") + : scaleType == 0 ? page->Scale.getValue() : base->Scale.getValue(); + const double x = number("x"), y = number("y"); + const auto reference = text("reference"); + if (radius <= 0 || !std::isfinite(scale) || scale <= 0) + throw std::runtime_error("radius and scale must be positive"); + TechDraw::DrawViewDetail* detail = nullptr; + const bool editing = args.contains(QStringLiteral("view")); + if (editing) { + detail = dynamic_cast( + document->getObject(text("view").toUtf8().constData())); + if (!detail || detail->BaseView.getValue() != base || detail->findParentPage() != page) + throw std::runtime_error("Detail view must belong to the selected base and page"); + } + document->openTransaction(editing ? "Update Detail" : "Create Detail view"); + transaction = true; + if (!editing) + detail = dynamic_cast( + document->addObject("TechDraw::DrawViewDetail", "Detail")); + if (!detail) throw std::runtime_error("Native detail view creation failed"); + if (!editing) { + detail->Source.setValues(base->Source.getValues()); + detail->XSource.setValues(base->XSource.getValues()); + detail->BaseView.setValue(base); + detail->Direction.setValue(base->Direction.getValue()); + detail->XDirection.setValue(base->XDirection.getValue()); + page->addView(detail); + } + detail->AnchorPoint.setValue(Base::Vector3d(anchorX, anchorY, 0.0)); + detail->Radius.setValue(radius); + detail->ScaleType.setValue(scaleType); + detail->Scale.setValue(scale); + detail->Reference.setValue(reference.toUtf8().constData()); + detail->X.setValue(x); + detail->Y.setValue(y); + bool hasError = false; + document->recompute({}, false, &hasError); + if (hasError || detail->isError() || detail->getEdgeGeometry().empty()) + throw std::runtime_error("Detail boundary produced no usable native geometry"); + const QString svg = renderDetail(detail); + QJsonObject state { + {QStringLiteral("document"), QString::fromUtf8(document->getName())}, + {QStringLiteral("page"), QString::fromUtf8(page->getNameInDocument())}, + {QStringLiteral("view"), QString::fromUtf8(detail->getNameInDocument())}, + {QStringLiteral("baseView"), QString::fromUtf8(base->getNameInDocument())}, + {QStringLiteral("scaleType"), static_cast(detail->ScaleType.getValue())}, + {QStringLiteral("scale"), detail->Scale.getValue()}, + {QStringLiteral("radius"), detail->Radius.getValue()}, + {QStringLiteral("anchorX"), detail->AnchorPoint.getValue().x}, + {QStringLiteral("anchorY"), detail->AnchorPoint.getValue().y}, + {QStringLiteral("x"), detail->X.getValue()}, + {QStringLiteral("y"), detail->Y.getValue()}, + {QStringLiteral("reference"), QString::fromUtf8(detail->Reference.getValue())}, + {QStringLiteral("edgeCount"), static_cast(detail->getEdgeGeometry().size())}, + {QStringLiteral("svg"), svg}, + {QStringLiteral("baseSvg"), renderDetail(base)}, + {QStringLiteral("nativeOperationCompleted"), true} + }; + document->commitTransaction(); + transaction = false; + result.insert(QStringLiteral("result"), state); + // Keep the completion marker in the command-specific branch: the + // native object, recomputed geometry and rendered SVG above are the + // Web equivalent of accepting the desktop detail task. + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command")); + result.insert(QStringLiteral("message"), QStringLiteral( + "Native detail view created or updated and rendered for the current page.")); + } catch (const Base::Exception& error) { fail(error.what()); } + catch (const std::exception& error) { fail(error.what()); } + return true; + } + const bool symbol = name == QStringLiteral("TechDraw_Symbol"); + const bool sheet = name == QStringLiteral("TechDraw_SpreadsheetView"); + const bool balloon = name == QStringLiteral("TechDraw_Balloon"); + if (name == QStringLiteral("TechDraw_ExportPageDXF")) { + // FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExportPageDXF") + QJsonObject args = input.value(QStringLiteral("args")).toObject(); + if (args.value(QStringLiteral("args")).isObject()) + args = args.value(QStringLiteral("args")).toObject(); + result.insert(QStringLiteral("parameterSchema"), QStringLiteral( + "{document:string,page:string,allowOmitBimViews?:boolean}")); + auto decref = [](PyObject* object) { Py_XDECREF(object); }; + using PythonRef = std::unique_ptr; + try { + if (input.value(QStringLiteral("apiId")).toString() != QStringLiteral("TechDraw:desktop:") + name) + throw std::runtime_error("Desktop command ID/name mismatch"); + const auto documentName = args.value(QStringLiteral("document")).toString(); + const auto pageName = args.value(QStringLiteral("page")).toString(); + if (documentName.isEmpty() || pageName.isEmpty()) + throw std::runtime_error("document and page handles are required"); + if (args.contains(QStringLiteral("allowOmitBimViews")) + && !args.value(QStringLiteral("allowOmitBimViews")).isBool()) + throw std::runtime_error("allowOmitBimViews must be boolean"); + auto* document = App::GetApplication().getDocument(documentName.toUtf8().constData()); + if (!document) throw std::runtime_error("Document does not exist"); + auto* page = dynamic_cast(document->getObject(pageName.toUtf8().constData())); + if (!page) throw std::runtime_error("Selected page does not exist or is not a DrawPage"); + PythonRef globals(PyDict_New(), decref); + PythonRef pageObject(page->getPyObject(), decref); + if (!globals || !pageObject + || PyDict_SetItemString(globals.get(), "__builtins__", PyEval_GetBuiltins()) < 0 + || PyDict_SetItemString(globals.get(), "_page", pageObject.get()) < 0 + || PyDict_SetItemString(globals.get(), "_allow_bim", + args.value(QStringLiteral("allowOmitBimViews")).toBool(false) ? Py_True : Py_False) < 0) + throw std::runtime_error("Could not initialize native DXF export context"); + // Keep user data in Python objects; filenames are private to this export. + PythonRef executed(PyRun_String( + "import TechDraw, tempfile, os\n" + "if any(v.isDerivedFrom('TechDraw::DrawViewArch') for v in _page.Views) and not _allow_bim:\n" + " raise ValueError('Page contains BIM views; explicit allowOmitBimViews confirmation is required')\n" + "with tempfile.TemporaryDirectory(prefix='techdraw-dxf-') as _directory:\n" + " _path = os.path.join(_directory, 'page.dxf')\n" + " TechDraw.writeDXFPage(_page, _path)\n" + " with open(_path, 'rb') as _file:\n" + " _content = _file.read()\n", + Py_file_input, globals.get(), globals.get()), decref); + if (!executed) throw std::runtime_error("Native writeDXFPage failed; check page geometry and BIM omission confirmation"); + auto* content = PyDict_GetItemString(globals.get(), "_content"); + if (!content || !PyBytes_Check(content) || PyBytes_Size(content) <= 0) + throw std::runtime_error("Native DXF exporter produced no file content"); + const QByteArray bytes(PyBytes_AsString(content), static_cast(PyBytes_Size(content))); + result.insert(QStringLiteral("result"), QJsonObject { + {QStringLiteral("document"), documentName}, + {QStringLiteral("page"), pageName}, + {QStringLiteral("filename"), pageName + QStringLiteral(".dxf")}, + {QStringLiteral("mimeType"), QStringLiteral("image/vnd.dxf")}, + {QStringLiteral("encoding"), QStringLiteral("base64")}, + {QStringLiteral("content"), QString::fromLatin1(bytes.toBase64())}, + {QStringLiteral("byteLength"), bytes.size()}, + {QStringLiteral("nativeOperationCompleted"), true} + }); + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + result.insert(QStringLiteral("message"), QStringLiteral( + "Native page DXF exported for browser file delivery.")); + } catch (const std::exception& error) { + PyErr_Clear(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } catch (const Base::Exception& error) { + PyErr_Clear(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } + return true; + } + if (name == QStringLiteral("TechDraw_View")) { + QJsonObject args = input.value(QStringLiteral("args")).toObject(); + if (args.value(QStringLiteral("args")).isObject()) + args = args.value(QStringLiteral("args")).toObject(); + result.insert(QStringLiteral("parameterSchema"), QStringLiteral( + "{document:string,page:string,sources:string[],direction:[number,number,number],xDirection:[number,number,number],scale?:number,x?:number,y?:number,taskActive?:boolean}")); + App::Document* document = nullptr; + bool transaction = false; + auto fail = [&](const char* message) { + if (transaction) { document->abortTransaction(); transaction = false; } + PyErr_Clear(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(message)); + }; + try { + if (input.value(QStringLiteral("apiId")).toString() != QStringLiteral("TechDraw:desktop:") + name) + throw std::runtime_error("Desktop command ID/name mismatch"); + if (args.contains(QStringLiteral("taskActive")) + && !args.value(QStringLiteral("taskActive")).isBool()) + throw std::runtime_error("taskActive must be boolean"); + if (args.value(QStringLiteral("taskActive")).toBool()) + throw std::runtime_error("Finish the active task first"); + auto required = [&](const char* key) { + const auto value = args.value(QString::fromLatin1(key)); + if (!value.isString() || value.toString().isEmpty()) + throw std::runtime_error(std::string(key) + " must be a nonempty string"); + return value.toString(); + }; + auto vector = [&](const char* key) { + const auto values = args.value(QString::fromLatin1(key)).toArray(); + if (values.size() != 3) throw std::runtime_error("Projection vectors require three numbers"); + for (const auto& value : values) + if (!value.isDouble() || !std::isfinite(value.toDouble())) + throw std::runtime_error("Projection vectors must be finite"); + Base::Vector3d v(values[0].toDouble(), values[1].toDouble(), values[2].toDouble()); + if (!std::isfinite(v.Length()) || v.Length() < 1e-12) + throw std::runtime_error("Projection vectors must be nonzero"); + v.Normalize(); + return v; + }; + document = App::GetApplication().getDocument(required("document").toUtf8().constData()); + if (!document) throw std::runtime_error("Document does not exist"); + auto* page = dynamic_cast(document->getObject(required("page").toUtf8().constData())); + if (!page) throw std::runtime_error("Selected page does not exist or is not a DrawPage"); + auto numeric = [&](const char* key, double fallback) { + const auto value = args.value(QString::fromLatin1(key)); + if (value.isUndefined()) return fallback; + if (!value.isDouble() || !std::isfinite(value.toDouble())) + throw std::runtime_error(std::string(key) + " must be a finite number"); + return value.toDouble(); + }; + const double scale = numeric("scale", page->Scale.getValue()); + if (!std::isfinite(scale) || scale <= 0) + throw std::runtime_error("Projection scale must be positive and finite"); + const double x = numeric("x", 0.0); + const double y = numeric("y", 0.0); + const auto direction = vector("direction"); + const auto xDirection = vector("xDirection"); + if (direction.Cross(xDirection).Length() < 1e-12) + throw std::runtime_error("Projection direction and X direction must not be parallel"); + const auto handles = args.value(QStringLiteral("sources")).toArray(); + if (handles.isEmpty()) throw std::runtime_error("Select source objects in the current document"); + std::vector sources; + for (const auto& handle : handles) { + if (!handle.isString() || handle.toString().isEmpty()) + throw std::runtime_error("Source handles must be nonempty strings"); + auto* source = document->getObject(handle.toString().toUtf8().constData()); + if (!source) throw std::runtime_error("Source does not exist in the current document"); + if (dynamic_cast(source) || dynamic_cast(source)) + continue; + if (std::find(sources.begin(), sources.end(), source) == sources.end()) + sources.push_back(source); + } + if (sources.empty()) throw std::runtime_error("Selection contains no projection sources"); + document->openTransaction("Create view"); + transaction = true; + auto* view = dynamic_cast( + document->addObject("TechDraw::DrawProjGroupItem", "View")); + if (!view) throw std::runtime_error("Native projection view creation failed"); + page->addView(view); + if (args.contains(QStringLiteral("scale"))) { + view->ScaleType.setValue("Custom"); + view->Scale.setValue(scale); + } + if (args.contains(QStringLiteral("x"))) view->X.setValue(x); + if (args.contains(QStringLiteral("y"))) view->Y.setValue(y); + view->Source.setValues(sources); + view->Direction.setValue(direction); + view->XDirection.setValue(xDirection); + bool hasError = false; + document->recompute({}, false, &hasError); + if (hasError || view->isError() || view->getEdgeGeometry().empty()) + throw std::runtime_error("Native projection did not produce usable edge geometry"); + QJsonObject state; + state.insert(QStringLiteral("document"), QString::fromUtf8(document->getName())); + state.insert(QStringLiteral("page"), QString::fromUtf8(page->getNameInDocument())); + state.insert(QStringLiteral("view"), QString::fromUtf8(view->getNameInDocument())); + state.insert(QStringLiteral("scale"), view->getScale()); + state.insert(QStringLiteral("x"), view->X.getValue()); + state.insert(QStringLiteral("y"), view->Y.getValue()); + state.insert(QStringLiteral("edgeCount"), static_cast(view->getEdgeGeometry().size())); + auto decref = [](PyObject* object) { Py_XDECREF(object); }; + using PythonRef = std::unique_ptr; + PythonRef module(PyImport_ImportModule("TechDraw"), decref); + PythonRef renderer(module ? PyObject_GetAttrString(module.get(), "viewPartAsSvg") : nullptr, decref); + PythonRef viewObject(view->getPyObject(), decref); + PythonRef rendered(renderer && viewObject + ? PyObject_CallFunctionObjArgs(renderer.get(), viewObject.get(), nullptr) : nullptr, decref); + const char* svg = rendered && PyUnicode_Check(rendered.get()) + ? PyUnicode_AsUTF8(rendered.get()) : nullptr; + if (!svg || !*svg) { + PyErr_Clear(); + throw std::runtime_error("Native viewPartAsSvg did not produce SVG"); + } + state.insert(QStringLiteral("svg"), QString::fromUtf8(svg)); + state.insert(QStringLiteral("nativeOperationCompleted"), true); + document->commitTransaction(); + transaction = false; + result.insert(QStringLiteral("result"), state); + // The initial native view is usable, but the complete desktop workflow is still pending. + result.insert(QStringLiteral("status"), QStringLiteral("partial")); + result.insert(QStringLiteral("code"), QStringLiteral("implementation_required")); + result.insert(QStringLiteral("message"), QStringLiteral( + "Initial current-document projection created. Remaining: face/link selection, spreadsheet/file insertion, rendered Web view and TaskDlgProjGroup equivalent.")); + } catch (const Base::Exception& error) { fail(error.what()); } + catch (const std::exception& error) { fail(error.what()); } + return true; + } + if (!symbol && !sheet && !balloon) { + const QJsonObject dependencies { + {QStringLiteral("TechDraw_ActiveView"), QStringLiteral("Requires current React viewport capture, TaskDlgActiveView parameters and native page insertion contract; no viewport capture handler is connected")}, + {QStringLiteral("TechDraw_View"), QStringLiteral("Selected current-document source objects, page selection, projection direction and native DrawViewPart creation/recompute with rendered geometry")}, + {QStringLiteral("TechDraw_ArchView"), QStringLiteral("Requires native BIM section-plane validation, DrawViewArch Source assignment, page addView and page Scale inheritance, plus BIM renderer and Web page display")}, + {QStringLiteral("TechDraw_BrokenView"), QStringLiteral("Source selection, break definitions and broken-view task execution")}, + {QStringLiteral("TechDraw_ComplexSection"), QStringLiteral("Requires active-task exclusion, execComplexSection selection/profile contract and native complex-section task execution with Web page display")}, + {QStringLiteral("TechDraw_DetailView"), QStringLiteral("TaskDlgDetail base view, boundary and placement")}, + {QStringLiteral("TechDraw_DraftView"), QStringLiteral("Requires current-document non-TechDraw source selection, per-source DrawViewDraft creation, Source/page assignment, viewport direction/rotation, native Draft renderer and rendered Web page insertion")}, + {QStringLiteral("TechDraw_ExportPageDXF"), QStringLiteral("writeDXFPage and browser file delivery, including BIM-view confirmation")}, + {QStringLiteral("TechDraw_ExportPageSVG"), QStringLiteral("Requires selected current-document page, complete native page scene equivalent to MDIViewPage::saveSVG (template and all views), and browser file delivery; a single view SVG is not a page export")}, + {QStringLiteral("TechDraw_PrintAll"), QStringLiteral("Requires MDIViewPage::printAllPages equivalent over actual rendered document pages, page layout and browser print interaction")}, + {QStringLiteral("TechDraw_ProjectShape"), QStringLiteral("Requires active-task exclusion, TaskDlgProjection parameter collection, selected current-document shapes and native projection execution with document result insertion")}, + {QStringLiteral("TechDraw_ProjectionGroup"), QStringLiteral("Requires current-document source/face/link selection, native DrawProjGroup creation, projection direction and task parameters, recomputed group geometry and Web page display")}, + {QStringLiteral("TechDraw_SectionGroup"), QStringLiteral("Requires active-task exclusion and iMsg dispatch (0 execSimpleSection, 1 execComplexSection), validated base view/profile inputs, corresponding native section execution and Web task/page display")}, + {QStringLiteral("TechDraw_SectionView"), QStringLiteral("execSimpleSection base view and section task execution")} + }; + if (!dependencies.contains(name)) return false; + result.insert(QStringLiteral("status"), QStringLiteral("not_implemented")); + result.insert(QStringLiteral("code"), QStringLiteral("implementation_required")); + result.insert(QStringLiteral("message"), dependencies.value(name)); + return true; + } + QJsonObject args = input.value(QStringLiteral("args")).toObject(); + if (args.value(QStringLiteral("args")).isObject()) args = args.value(QStringLiteral("args")).toObject(); + result.insert(QStringLiteral("parameterSchema"), balloon + ? QStringLiteral("{document:string,page:string,owner:string,origin:[x:number,y:number]} (origin: unscaled, unrotated owner-view coordinates, millimetres, Y up)") + : symbol + ? QStringLiteral("{document:string,page:string,svg:string,owner?:string}") + : QStringLiteral("{document:string,page:string,source:string,cellStart:string,cellEnd:string,owner?:string}")); + App::Document* document = nullptr; + bool transaction = false; + auto fail = [&](const char* message) { + if (transaction) { document->abortTransaction(); transaction = false; } + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(message)); + }; + try { + if (input.value(QStringLiteral("apiId")).toString() != QStringLiteral("TechDraw:desktop:") + name) + throw std::runtime_error("Desktop command ID/name mismatch"); + auto required = [&](const char* key) { + const auto value = args.value(QString::fromLatin1(key)); + if (!value.isString() || value.toString().isEmpty()) + throw std::runtime_error(std::string(key) + " must be a nonempty string"); + return value.toString(); + }; + const auto documentName = required("document"); + document = App::GetApplication().getDocument(documentName.toUtf8().constData()); + if (!document) throw std::runtime_error("Document does not exist"); + auto* page = dynamic_cast(document->getObject(required("page").toUtf8().constData())); + if (!page) throw std::runtime_error("Selected page does not exist or is not a DrawPage"); + TechDraw::DrawView* owner = nullptr; + if (args.contains(QStringLiteral("owner"))) { + owner = dynamic_cast(document->getObject(required("owner").toUtf8().constData())); + bool onPage = false; + for (auto* view : page->Views.getValues()) if (view && view == owner) onPage = true; + if (!onPage) throw std::runtime_error("Owner must be a view on the selected page"); + } + if (name == QStringLiteral("TechDraw_Balloon")) { + if (!owner) throw std::runtime_error("Select one owner view on the current page"); + const auto origin = args.value(QStringLiteral("origin")).toArray(); + if (origin.size() != 2 || !origin[0].isDouble() || !origin[1].isDouble() + || !std::isfinite(origin[0].toDouble()) || !std::isfinite(origin[1].toDouble())) + throw std::runtime_error("origin must contain two finite owner-view coordinates"); + const double scale = owner->getScale(); + if (!std::isfinite(scale) || scale <= 0) + throw std::runtime_error("Owner view scale must be finite and positive"); + const double offset = 20.0 / scale; + const double x = origin[0].toDouble(); + const double y = origin[1].toDouble(); + if (!std::isfinite(offset) || !std::isfinite(x + offset) || !std::isfinite(y + offset)) + throw std::runtime_error("Balloon position exceeds the numeric range"); + document->openTransaction("Create Balloon"); + transaction = true; + auto* view = document->addObject("Balloon"); + if (!view) throw std::runtime_error("Balloon creation failed"); + view->translateLabel("DrawViewBalloon", "Balloon", view->getNameInDocument()); + view->SourceView.setValue(owner); + // The Web parameter surface supplies canonical coordinates in place of QGI scene mapping. + view->setOrigin(Base::Vector3d(x, y, 0.0)); + view->setPosition(x + offset, y + offset); + view->Text.setValue(std::to_string(page->getNextBalloonIndex()).c_str()); + page->addView(view); + owner->touch(true); + document->recompute(); + QJsonObject state; + state.insert(QStringLiteral("document"), QString::fromUtf8(document->getName())); + state.insert(QStringLiteral("page"), QString::fromUtf8(page->getNameInDocument())); + state.insert(QStringLiteral("object"), QString::fromUtf8(view->getNameInDocument())); + state.insert(QStringLiteral("owner"), QString::fromUtf8(view->SourceView.getValue()->getNameInDocument())); + state.insert(QStringLiteral("origin"), QJsonArray{view->OriginX.getValue(), view->OriginY.getValue()}); + state.insert(QStringLiteral("position"), QJsonArray{view->X.getValue(), view->Y.getValue()}); + state.insert(QStringLiteral("text"), QString::fromUtf8(view->Text.getValue())); + QJsonArray views; + for (auto* item : page->Views.getValues()) + if (item) views.append(QString::fromUtf8(item->getNameInDocument())); + state.insert(QStringLiteral("views"), views); + document->commitTransaction(); + transaction = false; + result.insert(QStringLiteral("result"), state); + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command")); + return true; + } + QString svg, cellStart, cellEnd; + App::DocumentObject* source = nullptr; + if (symbol) { + svg = required("svg"); + QDomDocument xml; + if (!xml.setContent(svg) || xml.documentElement().tagName() != QStringLiteral("svg")) + throw std::runtime_error("svg must contain a valid SVG document"); + } else { + source = document->getObject(required("source").toUtf8().constData()); + const auto sheetType = Base::Type::fromName("Spreadsheet::Sheet"); + if (!source || sheetType == Base::Type() || !source->isDerivedFrom(sheetType)) + throw std::runtime_error("Select exactly one spreadsheet object in the current document"); + cellStart = required("cellStart"); + cellEnd = required("cellEnd"); + } + document->openTransaction(name.toUtf8().constData()); + transaction = true; + TechDraw::DrawViewSymbol* view = nullptr; + if (name == QStringLiteral("TechDraw_SpreadsheetView")) { + auto* spreadsheet = document->addObject("Sheet"); + if (!spreadsheet) throw std::runtime_error("Spreadsheet view creation failed"); + view = spreadsheet; + spreadsheet->Source.setValue(source); + spreadsheet->CellStart.setValue(cellStart.toUtf8().constData()); + spreadsheet->CellEnd.setValue(cellEnd.toUtf8().constData()); + if (owner) spreadsheet->Owner.setValue(owner); + if (spreadsheet->getSheetImage().empty()) throw std::runtime_error("Spreadsheet range could not be rendered"); + view->translateLabel("DrawViewSpreadsheet", "Sheet", view->getNameInDocument()); + } else if (name == QStringLiteral("TechDraw_Symbol")) { + view = document->addObject("Symbol"); + if (!view) throw std::runtime_error("Symbol creation failed"); + view->Symbol.setValue(svg.toUtf8().constData()); + if (owner) view->Owner.setValue(owner); + view->translateLabel("DrawViewSymbol", "Symbol", view->getNameInDocument()); + } + page->addView(view); + document->recompute(); + const QString rendered = QString::fromUtf8(view->Symbol.getValue()); + if (rendered.isEmpty()) throw std::runtime_error("Native view produced no SVG"); + QJsonObject state; + state.insert(QStringLiteral("document"), QString::fromUtf8(document->getName())); + state.insert(QStringLiteral("page"), QString::fromUtf8(page->getNameInDocument())); + state.insert(QStringLiteral("object"), QString::fromUtf8(view->getNameInDocument())); + state.insert(QStringLiteral("svg"), rendered); + auto* actualOwner = view->getOwnerProperty()->getValue(); + state.insert(QStringLiteral("owner"), actualOwner + ? QJsonValue(QString::fromUtf8(actualOwner->getNameInDocument())) : QJsonValue(QJsonValue::Null)); + if (sheet) { + auto* spreadsheet = static_cast(view); + state.insert(QStringLiteral("source"), QString::fromUtf8(spreadsheet->Source.getValue()->getNameInDocument())); + state.insert(QStringLiteral("cellStart"), QString::fromUtf8(spreadsheet->CellStart.getValue())); + state.insert(QStringLiteral("cellEnd"), QString::fromUtf8(spreadsheet->CellEnd.getValue())); + } + QJsonArray views; + for (auto* item : page->Views.getValues()) if (item) views.append(QString::fromUtf8(item->getNameInDocument())); + state.insert(QStringLiteral("views"), views); + document->commitTransaction(); + transaction = false; + result.insert(QStringLiteral("result"), state); + if (name == QStringLiteral("TechDraw_SpreadsheetView")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_Symbol")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command")); + } catch (const Base::Exception& error) { fail(error.what()); } + catch (const std::exception& error) { fail(error.what()); } + return true; +} + +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_Midpoints") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_Quadrants") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_CosmeticVertex") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_CosmeticVertexGroup") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_CosmeticEraser") +bool dispatchAnnotationGeometry(const QString& name, const QJsonObject& input, + QJsonObject& result) +{ + QJsonObject args = input.value(QStringLiteral("args")).toObject(); + if (args.value(QStringLiteral("args")).isObject()) args = args.value(QStringLiteral("args")).toObject(); + if (name == QStringLiteral("TechDraw_CosmeticEraser")) { + result.insert(QStringLiteral("parameterSchema"), QStringLiteral( + "{document:string,page:string,view?:string,selection?:string[],selections?:{view:string,selection:string[]}[]}")); + App::Document* document = nullptr; + bool transaction = false; + try { + if (input.value(QStringLiteral("apiId")).toString() != QStringLiteral("TechDraw:desktop:") + name) + throw std::runtime_error("Desktop command ID/name mismatch"); + const auto documentName = args.value(QStringLiteral("document")).toString(); + const auto pageName = args.value(QStringLiteral("page")).toString(); + if (documentName.isEmpty() || pageName.isEmpty()) + throw std::runtime_error("document and page handles are required"); + document = App::GetApplication().getDocument(documentName.toUtf8().constData()); + if (!document) throw std::runtime_error("Document does not exist"); + auto* page = dynamic_cast(document->getObject(pageName.toUtf8().constData())); + if (!page) throw std::runtime_error("Existing DrawPage is required"); + QJsonArray selections; + if (args.contains(QStringLiteral("selections"))) { + if (!args.value(QStringLiteral("selections")).isArray()) + throw std::runtime_error("selections must be an array"); + selections = args.value(QStringLiteral("selections")).toArray(); + } else { + QJsonObject selected; + selected.insert(QStringLiteral("view"), args.value(QStringLiteral("view"))); + selected.insert(QStringLiteral("selection"), args.value(QStringLiteral("selection"))); + selections.append(selected); + } + if (selections.isEmpty()) throw std::runtime_error("Nothing selected"); + struct Removal { + TechDraw::DrawViewPart* view; + std::vector vertices, edges, centerlines; + }; + std::vector removals; + // Resolve every tag before any deletion can renumber projected geometry. + for (const auto& entry : selections) { + if (!entry.isObject()) throw std::runtime_error("Each selection must be an object"); + const auto selected = entry.toObject(); + const auto viewName = selected.value(QStringLiteral("view")).toString(); + if (viewName.isEmpty()) throw std::runtime_error("view handle is required"); + auto* view = dynamic_cast(document->getObject(viewName.toUtf8().constData())); + if (!view) throw std::runtime_error("Selected object is not a part view"); + bool onPage = false; + for (auto* member : page->Views.getValues()) if (member == view) onPage = true; + if (!onPage) throw std::runtime_error("Selected view does not belong to the page"); + const auto subs = selected.value(QStringLiteral("selection")); + if (!subs.isArray() || subs.toArray().isEmpty()) + throw std::runtime_error("Select at least one cosmetic edge or vertex"); + std::size_t slot = 0; + while (slot < removals.size() && removals[slot].view != view) ++slot; + if (slot == removals.size()) removals.push_back(Removal{view, {}, {}, {}}); + auto& removal = removals[slot]; + for (const auto& sub : subs.toArray()) { + if (!sub.isString()) throw std::runtime_error("Selection names must be strings"); + const auto subName = sub.toString().toStdString(); + const auto type = TechDraw::DrawUtil::getGeomTypeFromName(subName); + const int index = TechDraw::DrawUtil::getIndexFromName(subName); + if (index < 0 || subName != type + std::to_string(index + 1)) + throw std::runtime_error("Invalid subelement name"); + std::string tag; + std::vector* tags = nullptr; + if (type == "Edge") { + const auto geom = view->getGeomByIndex(index); + if (!geom) throw std::runtime_error("Selected edge does not exist"); + if (!geom->getCosmetic()) continue; + tag = geom->getCosmeticTag(); + const auto source = geom->source(); + if (source == decltype(source)::COSMETICEDGE) tags = &removal.edges; + else if (source == decltype(source)::CENTERLINE) tags = &removal.centerlines; + else throw std::runtime_error("Unknown cosmetic edge ownership"); + } else if (type == "Vertex") { + const auto vertex = view->getProjVertexByIndex(index); + if (!vertex) throw std::runtime_error("Selected vertex does not exist"); + tag = vertex->getCosmeticTag(); + tags = &removal.vertices; + } else throw std::runtime_error("Select edges or vertices"); + if (tag.empty()) continue; + bool duplicate = false; + for (const auto& existing : *tags) if (existing == tag) duplicate = true; + if (!duplicate) tags->push_back(tag); + } + } + document->openTransaction("Erase TechDraw cosmetic geometry"); + transaction = true; + QJsonArray changed; + for (auto& removal : removals) { + if (!removal.vertices.empty()) removal.view->removeCosmeticVertex(removal.vertices); + if (!removal.edges.empty()) removal.view->removeCosmeticEdge(removal.edges); + if (!removal.centerlines.empty()) removal.view->removeCenterLine(removal.centerlines); + removal.view->recomputeFeature(); + QJsonObject state; + state.insert(QStringLiteral("view"), QString::fromUtf8(removal.view->getNameInDocument())); + auto nativeTags = [](const std::vector& tags) { + QJsonArray array; + for (const auto& tag : tags) array.append(QString::fromStdString(tag)); + return array; + }; + state.insert(QStringLiteral("removedVertices"), nativeTags(removal.vertices)); + state.insert(QStringLiteral("removedEdges"), nativeTags(removal.edges)); + state.insert(QStringLiteral("removedCenterlines"), nativeTags(removal.centerlines)); + changed.append(state); + } + QJsonObject state; + state.insert(QStringLiteral("document"), QString::fromUtf8(document->getName())); + state.insert(QStringLiteral("page"), QString::fromUtf8(page->getNameInDocument())); + state.insert(QStringLiteral("views"), changed); + document->commitTransaction(); + transaction = false; + result.insert(QStringLiteral("result"), state); + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command")); + } catch (const Base::Exception& error) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } catch (const std::exception& error) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } + return true; + } + // FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_LeaderLine") + if (name == QStringLiteral("TechDraw_LeaderLine")) { + result.insert(QStringLiteral("parameterSchema"), QStringLiteral( + "{document:string,page:string,view:string,origin:[x:number,y:number],points:[x:number,y:number][],scalable?:boolean,autoHorizontal?:boolean,rotatesWithParent?:boolean} (millimetres, Y up; origin relative to base view, points are unrotated page offsets from origin, starting at [0,0])")); + App::Document* document = nullptr; + bool transaction = false; + try { + if (input.value(QStringLiteral("apiId")).toString() != QStringLiteral("TechDraw:desktop:") + name) + throw std::runtime_error("Desktop command ID/name mismatch"); + auto required = [&](const char* key) { + const auto value = args.value(QString::fromLatin1(key)); + if (!value.isString() || value.toString().isEmpty()) + throw std::runtime_error("document, page and view handles are required"); + return value.toString().toStdString(); + }; + document = App::GetApplication().getDocument(required("document").c_str()); + if (!document) throw std::runtime_error("Document does not exist"); + auto* page = dynamic_cast(document->getObject(required("page").c_str())); + auto* view = dynamic_cast(document->getObject(required("view").c_str())); + if (!page || !view) throw std::runtime_error("Existing page and base view are required"); + bool onPage = false; + for (auto* member : page->Views.getValues()) if (member == view) onPage = true; + if (!onPage) throw std::runtime_error("Base view does not belong to the page"); + auto point = [](const QJsonValue& value) { + const auto pair = value.toArray(); + if (!value.isArray() || pair.size() != 2 || !pair[0].isDouble() || !pair[1].isDouble() + || !std::isfinite(pair[0].toDouble()) || !std::isfinite(pair[1].toDouble())) + throw std::runtime_error("Each point must contain two finite coordinates"); + return Base::Vector3d(pair[0].toDouble(), pair[1].toDouble(), 0); + }; + auto origin = point(args.value(QStringLiteral("origin"))); + const auto points = args.value(QStringLiteral("points")); + if (!points.isArray() || points.toArray().size() < 2) + throw std::runtime_error("At least two leader points are required"); + std::vector deltas; + for (const auto& value : points.toArray()) { + auto delta = point(value); + // DrawLeaderLine stores waypoint Y in the inverted page convention. + delta.y = -delta.y; + if (!deltas.empty() && (delta - deltas.back()).Length() <= 1e-12) + throw std::runtime_error("Consecutive leader points must be distinct"); + deltas.push_back(delta); + } + if (deltas.front().Length() > 1e-12) + throw std::runtime_error("The first offset must be [0,0]"); + for (const auto* key : {"scalable", "autoHorizontal", "rotatesWithParent"}) + if (args.contains(QString::fromLatin1(key)) && !args.value(QString::fromLatin1(key)).isBool()) + throw std::runtime_error("Leader options must be booleans"); + const double scale = view->getScale(); + const double rotation = view->Rotation.getValue(); + if (!std::isfinite(scale) || scale <= 0 || !std::isfinite(rotation)) + throw std::runtime_error("Base view scale or rotation is invalid"); + document->openTransaction("Create Leader"); + transaction = true; + auto* leader = document->addObject("LeaderLine"); + page->addView(leader); + leader->LeaderParent.setValue(view); + if (args.contains(QStringLiteral("scalable"))) + leader->Scalable.setValue(args.value(QStringLiteral("scalable")).toBool()); + if (args.contains(QStringLiteral("autoHorizontal"))) + leader->AutoHorizontal.setValue(args.value(QStringLiteral("autoHorizontal")).toBool()); + if (args.contains(QStringLiteral("rotatesWithParent"))) + leader->RotatesWithParent.setValue(args.value(QStringLiteral("rotatesWithParent")).toBool()); + origin.RotateZ(-rotation * std::acos(-1.0) / 180.0); + leader->setPosition(origin.x, origin.y, true); + leader->WayPoints.setValues(leader->makeCanonicalPointsInverted(deltas, true, false)); + view->touch(); + page->touch(); + document->recompute(); + leader->requestPaint(); + QJsonObject state; + state.insert(QStringLiteral("document"), QString::fromUtf8(document->getName())); + state.insert(QStringLiteral("page"), QString::fromUtf8(page->getNameInDocument())); + state.insert(QStringLiteral("object"), QString::fromUtf8(leader->getNameInDocument())); + state.insert(QStringLiteral("view"), QString::fromUtf8(leader->LeaderParent.getValue()->getNameInDocument())); + state.insert(QStringLiteral("x"), leader->X.getValue()); + state.insert(QStringLiteral("y"), leader->Y.getValue()); + QJsonArray storedPoints; + for (const auto& nativePoint : leader->WayPoints.getValues()) { + QJsonArray coordinates; + coordinates.append(nativePoint.x); + coordinates.append(nativePoint.y); + coordinates.append(nativePoint.z); + storedPoints.append(coordinates); + } + state.insert(QStringLiteral("wayPoints"), storedPoints); + state.insert(QStringLiteral("scalable"), leader->Scalable.getValue()); + state.insert(QStringLiteral("autoHorizontal"), leader->AutoHorizontal.getValue()); + state.insert(QStringLiteral("rotatesWithParent"), leader->RotatesWithParent.getValue()); + document->commitTransaction(); + transaction = false; + result.insert(QStringLiteral("result"), state); + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command")); + } catch (const Base::Exception& error) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } catch (const std::exception& error) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } + return true; + } + // FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_2LineCenterLine") + // FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_2PointCenterLine") + // FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_CenterLineGroup") + if (name == QStringLiteral("TechDraw_2LineCenterLine") + || name == QStringLiteral("TechDraw_2PointCenterLine") + || name == QStringLiteral("TechDraw_FaceCenterLine") + || name == QStringLiteral("TechDraw_CenterLineGroup")) { + result.insert(QStringLiteral("parameterSchema"), QStringLiteral( + "{document:string,page:string,view:string,selection:string[],activeTask?:boolean,action?:\"accept\"|\"cancel\",iMsg?:integer,orientation?:string,hShift?:number,vShift?:number,extend?:number,rotate?:number,flip?:boolean,width?:number,lineStyle?:integer}")); + App::Document* document = nullptr; + bool transaction = false; + try { + if (input.value(QStringLiteral("apiId")).toString() != QStringLiteral("TechDraw:desktop:") + name) + throw std::runtime_error("Desktop command ID/name mismatch"); + const auto activeTask = args.value(QStringLiteral("activeTask")); + if (!activeTask.isUndefined() && !activeTask.isBool()) + throw std::runtime_error("activeTask must be boolean"); + if (activeTask.toBool()) + throw std::runtime_error("Close active task dialog and try again"); + const auto actionValue = args.value(QStringLiteral("action")); + const auto action = actionValue.toString(QStringLiteral("accept")); + if ((!actionValue.isUndefined() && !actionValue.isString()) + || (action != QStringLiteral("accept") && action != QStringLiteral("cancel"))) + throw std::runtime_error("action must be accept or cancel"); + if (action == QStringLiteral("cancel")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + result.insert(QStringLiteral("code"), QStringLiteral("task_cancelled")); + result.insert(QStringLiteral("documentChanged"), false); + return true; + } + int kind = name == QStringLiteral("TechDraw_FaceCenterLine") ? 0 + : name == QStringLiteral("TechDraw_2LineCenterLine") ? 1 : 2; + if (name == QStringLiteral("TechDraw_CenterLineGroup")) { + const auto mode = args.value(QStringLiteral("iMsg")); + kind = mode.toInt(-1); + if (!mode.isDouble() || mode.toDouble() != kind || kind < 0 || kind > 2) + throw std::runtime_error("iMsg must be 0 (faces), 1 (two lines), or 2 (two points)"); + } + const auto documentName = args.value(QStringLiteral("document")).toString(); + const auto pageName = args.value(QStringLiteral("page")).toString(); + const auto viewName = args.value(QStringLiteral("view")).toString(); + if (documentName.isEmpty() || pageName.isEmpty() || viewName.isEmpty()) + throw std::runtime_error("document, page and view handles are required"); + document = App::GetApplication().getDocument(documentName.toUtf8().constData()); + if (!document) throw std::runtime_error("Document does not exist"); + auto* page = dynamic_cast(document->getObject(pageName.toUtf8().constData())); + auto* view = dynamic_cast(document->getObject(viewName.toUtf8().constData())); + if (!page || !view) throw std::runtime_error("Existing DrawPage and DrawViewPart are required"); + bool onPage = false; + for (auto* member : page->Views.getValues()) if (member == view) onPage = true; + if (!onPage) throw std::runtime_error("Selected view does not belong to the page"); + if (!std::isfinite(view->getScale()) || view->getScale() <= 0) + throw std::runtime_error("Invalid view scale"); + const auto selected = args.value(QStringLiteral("selection")).toArray(); + if (name == QStringLiteral("TechDraw_FaceCenterLine")) { + if (!args.value(QStringLiteral("selection")).isArray()) + throw std::runtime_error("selection must be an array of projected subelement names"); + bool hasFace = false; + bool hasEdge = false; + for (const auto& item : selected) { + if (!item.isString() || item.toString().isEmpty()) + throw std::runtime_error("Each selected subelement must be a nonempty string"); + const auto type = TechDraw::DrawUtil::getGeomTypeFromName(item.toString().toStdString()); + hasFace = hasFace || type == "Face"; + hasEdge = hasEdge || type == "Edge"; + } + if (!hasFace && hasEdge) { + result.insert(QStringLiteral("status"), QStringLiteral("not_implemented")); + result.insert(QStringLiteral("code"), QStringLiteral("implementation_required")); + result.insert(QStringLiteral("message"), QStringLiteral( + "Editing an existing centerline requires native lookup by selected edge and task edit persistence; face-based creation is available")); + return true; + } + } + if (selected.isEmpty() || (kind != 0 && selected.size() != 2)) + throw std::runtime_error("Select faces, exactly two edges, or exactly two vertices for the chosen command"); + std::vector subNames; + const std::string expected = kind == 0 ? "Face" : kind == 1 ? "Edge" : "Vertex"; + for (const auto& item : selected) { + const auto sub = item.toString().toStdString(); + // The official face command prioritizes faces in a mixed selection. + if (name == QStringLiteral("TechDraw_FaceCenterLine") + && TechDraw::DrawUtil::getGeomTypeFromName(sub) != "Face") continue; + if (TechDraw::DrawUtil::getGeomTypeFromName(sub) != expected + || TechDraw::DrawUtil::getIndexFromName(sub) < 0) + throw std::runtime_error("Selection type does not match the centerline command"); + if (kind == 0) { + bool validIndex = false; + const int faceIndex = QString::fromStdString(sub).mid(4).toInt(&validIndex); + if (!validIndex || faceIndex < 1 || !view->getFace(sub)) + throw std::runtime_error("Selected projected face does not exist in the current view"); + } + for (const auto& previous : subNames) + if (previous == sub) throw std::runtime_error("Duplicate centerline reference"); + subNames.push_back(sub); + } + if (subNames.empty()) + throw std::runtime_error("Select at least one face for a face centerline"); + using Mode = decltype(TechDraw::CenterLine::m_mode); + const auto orientation = args.value(QStringLiteral("orientation")).toString(QStringLiteral("vertical")); + Mode mode = Mode::VERTICAL; + if (orientation == QStringLiteral("horizontal")) mode = Mode::HORIZONTAL; + else if (orientation == QStringLiteral("aligned") && kind != 0) mode = Mode::ALIGNED; + else if (orientation != QStringLiteral("vertical")) + throw std::runtime_error("Invalid centerline orientation"); + auto number = [&](const char* key, double fallback) { + const auto value = args.value(QString::fromLatin1(key)); + if (value.isUndefined()) return fallback; + if (!value.isDouble() || !std::isfinite(value.toDouble())) + throw std::runtime_error("Centerline parameters must be finite numbers"); + return value.toDouble(); + }; + const double h = number("hShift", 0), v = number("vShift", 0); + const double extension = number("extend", 0), rotation = number("rotate", 0); + if (args.contains(QStringLiteral("flip")) && !args.value(QStringLiteral("flip")).isBool()) + throw std::runtime_error("flip must be boolean"); + const bool flip = args.value(QStringLiteral("flip")).toBool(false); + // Calculate through the same native reference geometry used by the task before persisting. + const auto ends = kind == 0 + ? TechDraw::CenterLine::calcEndPoints(view, subNames, mode, extension, h, v, rotation) + : kind == 1 + ? TechDraw::CenterLine::calcEndPoints2Lines(view, subNames, mode, extension, h, v, rotation, flip) + : TechDraw::CenterLine::calcEndPoints2Points(view, subNames, mode, extension, h, v, rotation, flip); + const auto finite = [](const Base::Vector3d& p) { + return std::isfinite(p.x) && std::isfinite(p.y) && std::isfinite(p.z); + }; + if (!finite(ends.first) || !finite(ends.second) || (ends.second - ends.first).Length() <= 1e-7) + throw std::runtime_error("Selection and orientation do not produce a valid centerline"); + std::unique_ptr line( + TechDraw::CenterLine::CenterLineBuilder(view, subNames, mode, flip)); + if (!line) throw std::runtime_error("Native centerline construction failed"); + line->setShifts(h, v); + line->setExtend(extension); + line->setRotate(rotation); + line->setFlip(flip); + const double width = number("width", line->m_format.getWidth()); + const double style = number("lineStyle", line->m_format.getLineNumber()); + if (width <= 0 || style < 1 || style > 5 || style != std::floor(style)) + throw std::runtime_error("Positive width and integer lineStyle from 1 to 5 are required"); + line->m_format.setWidth(width); + line->m_format.setLineNumber(static_cast(style)); + line->m_format.setVisible(true); + document->openTransaction(name.toUtf8().constData()); + transaction = true; + view->addCenterLine(line.get()); + line.release(); + view->recomputeFeature(); + view->requestPaint(); + document->commitTransaction(); + transaction = false; + result.insert(QStringLiteral("result"), QJsonObject{ + {QStringLiteral("document"), QString::fromUtf8(document->getName())}, + {QStringLiteral("view"), QString::fromUtf8(view->getNameInDocument())}, + {QStringLiteral("start"), QJsonArray{ends.first.x, ends.first.y, ends.first.z}}, + {QStringLiteral("end"), QJsonArray{ends.second.x, ends.second.y, ends.second.z}}}); + if (name == QStringLiteral("TechDraw_2LineCenterLine")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_2PointCenterLine")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_CenterLineGroup")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_FaceCenterLine")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command")); + } catch (const std::exception& error) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } catch (...) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QStringLiteral("Native centerline operation failed")); + } + return true; + } + // FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_2PointCosmeticLine") + if (name == QStringLiteral("TechDraw_2PointCosmeticLine")) { + result.insert(QStringLiteral("parameterSchema"), QStringLiteral( + "{document:string,page:string,view:string,selection?:string[],references3d?:{object:string,subelement:string}[],edit?:string,start?:number[2],end?:number[2]} (explicit endpoints: unscaled, unrotated view millimetres, Y up; edit: cosmetic Edge name)")); + App::Document* document = nullptr; + bool transaction = false; + try { + if (input.value(QStringLiteral("apiId")).toString() != QStringLiteral("TechDraw:desktop:") + name) + throw std::runtime_error("Desktop command ID/name mismatch"); + const auto documentName = args.value(QStringLiteral("document")).toString(); + const auto pageName = args.value(QStringLiteral("page")).toString(); + const auto viewName = args.value(QStringLiteral("view")).toString(); + if (documentName.isEmpty() || pageName.isEmpty() || viewName.isEmpty()) + throw std::runtime_error("document, page and view handles are required"); + document = App::GetApplication().getDocument(documentName.toUtf8().constData()); + if (!document) throw std::runtime_error("Document does not exist"); + auto* page = dynamic_cast(document->getObject(pageName.toUtf8().constData())); + auto* view = dynamic_cast(document->getObject(viewName.toUtf8().constData())); + if (!page || !view) throw std::runtime_error("Existing DrawPage and DrawViewPart are required"); + bool onPage = false; + for (auto* member : page->Views.getValues()) if (member == view) onPage = true; + if (!onPage) throw std::runtime_error("Selected view does not belong to the page"); + auto point = [&](const char* key) { + const auto value = args.value(QString::fromLatin1(key)); + const auto coordinates = value.toArray(); + if (!value.isArray() || coordinates.size() != 2 + || !coordinates[0].isDouble() || !coordinates[1].isDouble() + || !std::isfinite(coordinates[0].toDouble()) || !std::isfinite(coordinates[1].toDouble())) + throw std::runtime_error("start and end must contain two finite canonical view coordinates"); + return Base::Vector3d(coordinates[0].toDouble(), coordinates[1].toDouble(), 0.0); + }; + Base::Vector3d start, end; + QString editName = args.value(QStringLiteral("edit")).toString(); + if (args.contains(QStringLiteral("edit")) && editName.isEmpty()) + throw std::runtime_error("edit must name an existing cosmetic Edge"); + // The desktop command also enters edit mode from a single selected edge. + const auto selectedEntries = args.value(QStringLiteral("selection")).toArray(); + const bool selectedEdge = selectedEntries.size() == 1 + && selectedEntries[0].toString().startsWith(QStringLiteral("Edge")); + if (selectedEdge) { + if (!editName.isEmpty() && editName != selectedEntries[0].toString()) + throw std::runtime_error("Selected edge and edit handle disagree"); + editName = selectedEntries[0].toString(); + } + TechDraw::CosmeticEdge* editedEdge = nullptr; + if (!editName.isEmpty()) { + if (args.contains(QStringLiteral("references3d")) + || (args.contains(QStringLiteral("selection")) && !selectedEdge)) + throw std::runtime_error("Editing requires a cosmetic edge and explicit endpoints"); + bool validIndex = false; + const int edgeIndex = editName.mid(4).toInt(&validIndex); + if (!validIndex || edgeIndex < 1 || editName != QStringLiteral("Edge") + QString::number(edgeIndex)) + throw std::runtime_error("Invalid cosmetic Edge name"); + editedEdge = view->getCosmeticEdgeBySelection(editName.toStdString()); + if (!editedEdge || !editedEdge->m_geometry + || editedEdge->m_geometry->getGeomType() != TechDraw::GeomType::GENERIC) + throw std::runtime_error("Selection is not a cosmetic line"); + start = TechDraw::DrawUtil::invertY(point("start")); + end = TechDraw::DrawUtil::invertY(point("end")); + } else if (args.contains(QStringLiteral("selection")) || args.contains(QStringLiteral("references3d"))) { + const auto selectionValue = args.value(QStringLiteral("selection")); + const auto selection = selectionValue.toArray(); + if (args.contains(QStringLiteral("selection")) && !selectionValue.isArray()) + throw std::runtime_error("selection must be an array of projected Vertex names"); + if (args.contains(QStringLiteral("start")) || args.contains(QStringLiteral("end"))) + throw std::runtime_error("Supply either vertex references or explicit endpoints"); + auto selectedPoint = [&](const QJsonValue& entry) { + const auto subelement = entry.toString(); + if (!entry.isString() || !subelement.startsWith(QStringLiteral("Vertex"))) + throw std::runtime_error("Selection must contain projected Vertex names"); + bool validIndex = false; + const int index = subelement.mid(6).toInt(&validIndex); + if (!validIndex || index < 1 || subelement != QStringLiteral("Vertex") + QString::number(index)) + throw std::runtime_error("Invalid projected vertex name"); + const auto vertex = view->getProjVertexByIndex( + TechDraw::DrawUtil::getIndexFromName(subelement.toStdString())); + if (!vertex) throw std::runtime_error("Projected vertex does not exist in the selected view"); + const auto position = vertex->point(); + if (!std::isfinite(position.x) || !std::isfinite(position.y) || !std::isfinite(position.z)) + throw std::runtime_error("Projected vertex has nonfinite coordinates"); + return TechDraw::CosmeticVertex::makeCanonicalPointInverted(view, position); + }; + std::vector points; + for (const auto& entry : selection) points.push_back(selectedPoint(entry)); + if (args.contains(QStringLiteral("references3d"))) { + const auto referencesValue = args.value(QStringLiteral("references3d")); + if (!referencesValue.isArray()) + throw std::runtime_error("references3d must be an array of model vertex references"); + for (const auto& entry : referencesValue.toArray()) { + if (!entry.isObject()) throw std::runtime_error("Model reference requires object and subelement"); + const auto reference = entry.toObject(); + const auto objectName = reference.value(QStringLiteral("object")).toString(); + const auto subelement = reference.value(QStringLiteral("subelement")).toString(); + bool validIndex = false; + const int index = subelement.mid(6).toInt(&validIndex); + if (!validIndex || index < 1 || subelement != QStringLiteral("Vertex") + QString::number(index)) + throw std::runtime_error("Model reference must name a Vertex"); + auto* source = objectName.isEmpty() ? nullptr : dynamic_cast( + document->getObject(objectName.toUtf8().constData())); + if (!source) throw std::runtime_error("Model reference requires a Part feature in the current document"); + Part::TopoShape shape = source->Shape.getShape(); + const TopoDS_Vertex vertex = TopoDS::Vertex(shape.getSubShape(TopAbs_VERTEX, index)); + if (vertex.IsNull()) throw std::runtime_error("Model vertex does not exist"); + points.push_back(view->projectPoint( + TechDraw::DrawUtil::vertex2Vector(vertex) - view->getCurrentCentroid())); + } + } + if (points.size() != 2) throw std::runtime_error("Select exactly two vertices"); + start = points.front(); + end = points.back(); + } else { + start = TechDraw::DrawUtil::invertY(point("start")); + end = TechDraw::DrawUtil::invertY(point("end")); + } + if (!std::isfinite(start.x) || !std::isfinite(start.y) || !std::isfinite(start.z) + || !std::isfinite(end.x) || !std::isfinite(end.y) || !std::isfinite(end.z)) + throw std::runtime_error("Cosmetic line projection produced nonfinite endpoints"); + if ((end - start).Length() <= 1e-7) + throw std::runtime_error("Cosmetic line endpoints must be distinct"); + document->openTransaction(name.toUtf8().constData()); + transaction = true; + std::string tag; + if (editedEdge) { + const TopoDS_Edge edge = BRepBuilderAPI_MakeEdge( + gp_Pnt(start.x, start.y, start.z), gp_Pnt(end.x, end.y, end.z)); + const auto geometry = TechDraw::BaseGeom::baseFactory(edge); + if (!geometry) throw std::runtime_error("Native cosmetic line construction failed"); + editedEdge->permaStart = start; + editedEdge->permaEnd = end; + editedEdge->m_geometry = geometry; + } else { + tag = view->addCosmeticEdge(start, end); + editedEdge = view->getCosmeticEdge(tag); + if (!editedEdge) throw std::runtime_error("Native cosmetic line insertion failed"); + editedEdge->setFormat(TechDraw::LineFormat::getCurrentLineFormat()); + view->add1CEToGE(tag); + } + view->refreshCEGeoms(); + view->recomputeFeature(); + if (view->isError()) throw std::runtime_error("Cosmetic line view recomputation failed"); + view->requestPaint(); + const auto nativeStart = TechDraw::DrawUtil::invertY(editedEdge->permaStart); + const auto nativeEnd = TechDraw::DrawUtil::invertY(editedEdge->permaEnd); + document->commitTransaction(); + transaction = false; + result.insert(QStringLiteral("result"), QJsonObject{ + {QStringLiteral("document"), QString::fromUtf8(document->getName())}, + {QStringLiteral("view"), QString::fromUtf8(view->getNameInDocument())}, + {QStringLiteral("tag"), QString::fromStdString(tag)}, + {QStringLiteral("editedEdge"), editName}, + {QStringLiteral("start"), QJsonArray{nativeStart.x, nativeStart.y, nativeStart.z}}, + {QStringLiteral("end"), QJsonArray{nativeEnd.x, nativeEnd.y, nativeEnd.z}}}); + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command")); + } catch (const std::exception& error) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } catch (...) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QStringLiteral("Native cosmetic line operation failed")); + } + return true; + } + const bool group = name == QStringLiteral("TechDraw_CosmeticVertexGroup"); + const auto modeValue = args.value(QStringLiteral("iMsg")); + const int mode = modeValue.toInt(-1); + const bool vertex = name == QStringLiteral("TechDraw_CosmeticVertex") || (group && mode == 0); + const bool midpoints = name == QStringLiteral("TechDraw_Midpoints") || (group && mode == 1); + const bool quadrants = name == QStringLiteral("TechDraw_Quadrants") || (group && mode == 2); + if (name == QStringLiteral("TechDraw_RichTextAnnotation")) { + QJsonObject args = input.value(QStringLiteral("args")).toObject(); + if (args.value(QStringLiteral("args")).isObject()) args = args.value(QStringLiteral("args")).toObject(); + result.insert(QStringLiteral("parameterSchema"), QStringLiteral("{document:string,page:string,text:string[],owner?:string,x?:number,y?:number}")); + try { + const QString documentName = args.value(QStringLiteral("document")).toString(); + const QString pageName = args.value(QStringLiteral("page")).toString(); + const auto textValue = args.value(QStringLiteral("text")); + if (documentName.isEmpty() || pageName.isEmpty() || !textValue.isArray()) throw std::runtime_error("document, page and text[] are required"); + auto* document = App::GetApplication().getDocument(documentName.toUtf8().constData()); + auto* page = document ? dynamic_cast(document->getObject(pageName.toUtf8().constData())) : nullptr; + if (!page) throw std::runtime_error("Existing DrawPage is required"); + QStringList lines; + for (const auto& item : textValue.toArray()) { if (!item.isString()) throw std::runtime_error("text entries must be strings"); lines << item.toString(); } + if (lines.isEmpty()) throw std::runtime_error("text must not be empty"); + document->openTransaction("TechDraw_RichTextAnnotation"); + auto* annotation = document->addObject("Annotation"); + if (!annotation) throw std::runtime_error("Annotation creation failed"); + std::vector annotationLines; + annotationLines.reserve(static_cast(lines.size())); + for (const auto& line : lines) { + annotationLines.push_back(line.toStdString()); + } + annotation->Text.setValues(annotationLines); + if (args.value(QStringLiteral("x")).isDouble()) annotation->X.setValue(args.value(QStringLiteral("x")).toDouble()); + if (args.value(QStringLiteral("y")).isDouble()) annotation->Y.setValue(args.value(QStringLiteral("y")).toDouble()); + page->addView(annotation); document->recompute(); document->commitTransaction(); + result.insert(QStringLiteral("result"), QJsonObject{{"document", documentName}, {"page", pageName}, {"object", QString::fromUtf8(annotation->getNameInDocument())}, {"text", textValue}}); + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command")); + } catch (const std::exception& error) { result.insert(QStringLiteral("status"), QStringLiteral("error")); result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); } + return true; + } + if (!vertex && !midpoints && !quadrants && !group) { + struct Dependency { const char* command; const char* requirement; }; + static const Dependency pending[] = { + {"TechDraw_DecorateLine", "Requires active-task exclusion, current-page DrawViewPart and Edge selection, TaskDlgDecorateLine equivalent, native geometric/cosmetic edge format persistence and rendered Web view updates; cosmetic-only extension formatting is insufficient"}, + {"TechDraw_RichTextAnnotation", "Rich text task content and attachment require native rich annotation creation"}, + {"TechDraw_ShowAll", "ShowAllEdges requires persistent Web view state and equivalent edge rendering"}, + {"TechDraw_SurfaceFinishSymbols", "Surface finish command and task semantics require native symbol construction and attachment"}, + {"TechDraw_WeldSymbol", "Weld command and task semantics require native symbol construction and attachment"} + }; + for (const auto& item : pending) { + if (name != QString::fromLatin1(item.command)) continue; + result.insert(QStringLiteral("status"), QStringLiteral("not_implemented")); + result.insert(QStringLiteral("code"), QStringLiteral("implementation_required")); + result.insert(QStringLiteral("message"), QString::fromLatin1(item.requirement)); + return true; + } + return false; + } + result.insert(QStringLiteral("parameterSchema"), group ? QStringLiteral( + "{document:string,page:string,view:string,iMsg:integer,x?:number,y?:number,selection?:string[]}") + : vertex ? QStringLiteral("{document:string,page:string,view:string,x:number,y:number}") + : QStringLiteral("{document:string,page:string,view:string,selection:string[]}")); + App::Document* document = nullptr; + bool transaction = false; + auto fail = [&](const char* message) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(message)); + }; + try { + if (input.value(QStringLiteral("apiId")).toString() != QStringLiteral("TechDraw:desktop:") + name) + throw std::runtime_error("Desktop command ID/name mismatch"); + if (group && (!modeValue.isDouble() || mode < 0 || mode > 2 || modeValue.toDouble() != mode)) + throw std::runtime_error("iMsg must be 0 (vertex), 1 (midpoints), or 2 (quadrants)"); + const QString documentName = args.value(QStringLiteral("document")).toString(); + if (documentName.isEmpty()) throw std::runtime_error("document handle is required"); + document = App::GetApplication().getDocument(documentName.toUtf8().constData()); + if (!document) throw std::runtime_error("Document does not exist"); + const QString viewName = args.value(QStringLiteral("view")).toString(); + const QString pageName = args.value(QStringLiteral("page")).toString(); + if (viewName.isEmpty() || pageName.isEmpty()) throw std::runtime_error("page and view handles are required"); + auto* view = dynamic_cast(document->getObject(viewName.toUtf8().constData())); + auto* page = dynamic_cast(document->getObject(pageName.toUtf8().constData())); + if (!view || !page) throw std::runtime_error("Existing DrawPage and DrawViewPart are required"); + bool onPage = false; + for (auto* member : page->Views.getValues()) if (member == view) onPage = true; + if (!onPage) throw std::runtime_error("Selected view does not belong to the page"); + if (!std::isfinite(view->getScale()) || view->getScale() <= 0) + throw std::runtime_error("Invalid view scale"); + const auto selection = args.value(QStringLiteral("selection")).toArray(); + if (!vertex && selection.isEmpty()) throw std::runtime_error("Select at least one edge"); + const auto edges = view->getEdgeGeometry(); + std::vector points; + if (vertex) { + const auto x = args.value(QStringLiteral("x")); + const auto y = args.value(QStringLiteral("y")); + if (!x.isDouble() || !y.isDouble() || !std::isfinite(x.toDouble()) || !std::isfinite(y.toDouble())) + throw std::runtime_error("Finite numeric x and y coordinates are required"); + // TaskCosVertex::accept uses unscaled, unrotated conventional-Y coordinates. + points.emplace_back(x.toDouble(), y.toDouble(), 0.0); + } + if (!vertex) for (const auto& selected : selection) { + const auto sub = selected.toString().toStdString(); + if (TechDraw::DrawUtil::getGeomTypeFromName(sub) != "Edge") + throw std::runtime_error("Selection must contain edge names"); + const int index = TechDraw::DrawUtil::getIndexFromName(sub); + if (index < 0 || static_cast(index) >= edges.size() || !edges[index]) + throw std::runtime_error("Selected edge does not exist"); + const auto candidates = midpoints + ? std::vector{edges[index]->getMidPoint()} : edges[index]->getQuads(); + for (const auto& point : candidates) { + const auto canonical = TechDraw::CosmeticVertex::makeCanonicalPoint( + view, TechDraw::DrawUtil::invertY(point)); + if (!std::isfinite(canonical.x) || !std::isfinite(canonical.y) || !std::isfinite(canonical.z)) + throw std::runtime_error("Selected edge produced invalid coordinates"); + points.push_back(canonical); + } + } + document->openTransaction(name.toUtf8().constData()); + transaction = true; + QJsonArray coordinates; + for (const auto& point : points) { + view->addCosmeticVertex(point); + coordinates.append(QJsonArray{point.x, point.y, point.z}); + } + view->recomputeFeature(); + view->requestPaint(); + document->commitTransaction(); + transaction = false; + QJsonObject state; + state.insert(QStringLiteral("document"), QString::fromUtf8(document->getName())); + state.insert(QStringLiteral("view"), QString::fromUtf8(view->getNameInDocument())); + state.insert(QStringLiteral("canonicalPoints"), coordinates); + result.insert(QStringLiteral("result"), state); + if (name == QStringLiteral("TechDraw_CosmeticVertex")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_CosmeticVertexGroup")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_Midpoints")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_Quadrants")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command")); + } catch (const Base::Exception& error) { fail(error.what()); } + catch (const std::exception& error) { fail(error.what()); } + return true; +} + +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionDrawCosmCircle") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionThreadHoleBottom") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionThreadBoltBottom") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionVertexAtIntersection") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_CosmeticCircle") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionDrawCosmArc") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionDrawCosmCircle3Points") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionDrawCirclesGroup") +bool dispatchExtensionGeometry(const QString& name, const QJsonObject& input, + QJsonObject& result) +{ + if (name == QStringLiteral("TechDraw_ExtensionDrawCirclesGroup")) { + QJsonObject args = input.value(QStringLiteral("args")).toObject(); + if (args.value(QStringLiteral("args")).isObject()) args = args.value(QStringLiteral("args")).toObject(); + const auto mode = args.value(QStringLiteral("iMsg")); + const double index = mode.toDouble(-1); + if (input.value(QStringLiteral("apiId")).toString() != QStringLiteral("TechDraw:desktop:") + name + || !mode.isDouble() || !std::isfinite(index) || index < 0 || index > 3 + || std::floor(index) != index || args.value(QStringLiteral("taskActive")).toBool()) { + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QStringLiteral("Matching command ID, iMsg 0..3 and no active task are required")); + result.insert(QStringLiteral("parameterSchema"), QStringLiteral( + "{document:string,view:string,iMsg:integer,selection:string[],radius:number,style:integer,width:number,color:[r:number,g:number,b:number],visible:boolean}")); + return true; + } + const char* commands[] = {"TechDraw_CosmeticCircle", "TechDraw_ExtensionDrawCosmCircle", + "TechDraw_ExtensionDrawCosmCircle3Points", "TechDraw_ExtensionDrawCosmArc"}; + const QString command = QString::fromLatin1(commands[static_cast(index)]); + QJsonObject forwarded = input; + forwarded.insert(QStringLiteral("apiId"), QStringLiteral("TechDraw:desktop:") + command); + const bool handled = dispatchExtensionGeometry(command, forwarded, result); + if (handled && result.value(QStringLiteral("status")).toString() == QStringLiteral("exposed") + && result.value(QStringLiteral("code")).toString() == QStringLiteral("native_desktop_command")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } + return handled; + } + const bool circle = name == QStringLiteral("TechDraw_ExtensionDrawCosmCircle"); + const bool centerCircle = name == QStringLiteral("TechDraw_CosmeticCircle"); + const bool arc = name == QStringLiteral("TechDraw_ExtensionDrawCosmArc"); + const bool threePointCircle = name == QStringLiteral("TechDraw_ExtensionDrawCosmCircle3Points"); + const bool hole = name == QStringLiteral("TechDraw_ExtensionThreadHoleBottom"); + const bool bolt = name == QStringLiteral("TechDraw_ExtensionThreadBoltBottom"); + const bool intersection = name == QStringLiteral("TechDraw_ExtensionVertexAtIntersection"); + if (!circle && !centerCircle && !arc && !threePointCircle && !hole && !bolt && !intersection) { + QString dependency; + if (name == QStringLiteral("TechDraw_CosmeticCircle")) + dependency = QStringLiteral("Center selection and cosmetic-circle radius task contract"); + else if (name == QStringLiteral("TechDraw_ExtensionArcLengthAnnotation")) + dependency = QStringLiteral("Selected projected edges, OCCT length and balloon placement/units contract"); + else if (name == QStringLiteral("TechDraw_ExtensionAreaAnnotation")) + dependency = QStringLiteral("Selected projected faces, OCCT area/centroid and balloon placement/units contract"); + else if (name == QStringLiteral("TechDraw_ExtensionChangeLineAttributes")) + dependency = QStringLiteral("Selected cosmetic/centerline resolution and persistent active LineFormat contract"); + else if (name == QStringLiteral("TechDraw_ExtensionSelectLineAttributes")) + dependency = QStringLiteral("React line-attribute task and persistent active LineFormat contract"); + else if (name == QStringLiteral("TechDraw_ExtensionCircleCenterLines") + || name == QStringLiteral("TechDraw_ExtensionCircleCenterLinesGroup") + || name == QStringLiteral("TechDraw_ExtensionHoleCircle")) + dependency = QStringLiteral("Selected circles and execCircleCenterLines/execHoleCircle geometry with group mode dispatch"); + else if (name == QStringLiteral("TechDraw_ExtensionDrawCirclesGroup") + || name == QStringLiteral("TechDraw_ExtensionDrawCosmArc") + || name == QStringLiteral("TechDraw_ExtensionDrawCosmCircle3Points")) + dependency = QStringLiteral("Official selected-vertex arc/three-point-circle construction and complete group mode dispatch"); + else if (name == QStringLiteral("TechDraw_ExtensionThreadBoltSide") + || name == QStringLiteral("TechDraw_ExtensionThreadHoleSide")) + dependency = QStringLiteral("Official paired-line thread-side construction, canonical coordinates and thread factors"); + else if (name == QStringLiteral("TechDraw_ExtensionThreadsGroup")) + dependency = QStringLiteral("Complete iMsg dispatch: hole side (0), hole bottom (1), bolt side (2), bolt bottom (3); paired-line side construction is implementation_required"); + if (dependency.isEmpty()) return false; + result.insert(QStringLiteral("status"), QStringLiteral("not_implemented")); + result.insert(QStringLiteral("code"), QStringLiteral("implementation_required")); + result.insert(QStringLiteral("message"), dependency); + return true; + } + QJsonObject args = input.value(QStringLiteral("args")).toObject(); + if (args.value(QStringLiteral("args")).isObject()) args = args.value(QStringLiteral("args")).toObject(); + result.insert(QStringLiteral("parameterSchema"), QStringLiteral( + "{document:string,view:string,selection:string[],style:integer,width:number,color:[r:number,g:number,b:number],visible:boolean}")); + if (intersection) + result.insert(QStringLiteral("parameterSchema"), QStringLiteral( + "{document:string,view:string,selection:[string,string]}")); + if (centerCircle) + result.insert(QStringLiteral("parameterSchema"), QStringLiteral( + "{document:string,view:string,selection:[string],radius:number,style:integer,width:number,color:[r:number,g:number,b:number],visible:boolean}")); + App::Document* document = nullptr; + bool transaction = false; + auto fail = [&](const char* message) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(message)); + }; + try { + if (input.value(QStringLiteral("apiId")).toString() != QStringLiteral("TechDraw:desktop:") + name) + throw std::runtime_error("Desktop command ID/name mismatch"); + const QString documentName = args.value(QStringLiteral("document")).toString(); + if (documentName.isEmpty()) throw std::runtime_error("document handle is required"); + document = App::GetApplication().getDocument(documentName.toUtf8().constData()); + if (!document) throw std::runtime_error("Document does not exist"); + const QString viewName = args.value(QStringLiteral("view")).toString(); + auto* view = dynamic_cast(document->getObject(viewName.toUtf8().constData())); + if (!view || viewName.isEmpty()) throw std::runtime_error("view must identify an existing DrawViewPart"); + const double scale = view->getScale(); + if (!std::isfinite(scale) || scale <= 0) throw std::runtime_error("Invalid view scale"); + const auto selection = args.value(QStringLiteral("selection")).toArray(); + if (selection.isEmpty()) throw std::runtime_error("Selection is required"); + if (name == QStringLiteral("TechDraw_ExtensionVertexAtIntersection")) { + if (selection.size() < 2) + throw std::runtime_error("Select at least two projected edges"); + std::vector edges; + for (int i = 0; i < 2; ++i) { + const QString sub = selection[i].toString(); + bool valid = false; + const int number = sub.mid(4).toInt(&valid); + if (!sub.startsWith(QStringLiteral("Edge")) || !valid || number <= 0) + throw std::runtime_error("The first two selections must be projected Edge names"); + auto edge = view->getGeomByIndex(number - 1); + if (!edge) throw std::runtime_error("Selected projected edge does not exist"); + edges.push_back(edge); + } + const auto intersections = edges[0]->intersection(edges[1]); + std::vector points; + for (const auto& point : intersections) { + const auto canonical = TechDraw::CosmeticVertex::makeCanonicalPointInverted(view, point); + if (!std::isfinite(canonical.x) || !std::isfinite(canonical.y) + || !std::isfinite(canonical.z)) + throw std::runtime_error("Edge intersection produced invalid coordinates"); + points.push_back(canonical); + } + document->openTransaction(name.toUtf8().constData()); + transaction = true; + QJsonArray created; + for (const auto& point : points) { + view->addCosmeticVertex(point, false); + created.append(QJsonArray{point.x, point.y, point.z}); + } + view->refreshCVGeoms(); + view->requestPaint(); + document->commitTransaction(); + transaction = false; + QJsonObject typed; + typed.insert(QStringLiteral("document"), documentName); + typed.insert(QStringLiteral("view"), viewName); + typed.insert(QStringLiteral("cosmeticVertices"), created); + typed.insert(QStringLiteral("createdCount"), created.size()); + result.insert(QStringLiteral("result"), typed); + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command")); + return true; + } + const auto styleValue = args.value(QStringLiteral("style")); + const double style = styleValue.toDouble(-1); + const auto widthValue = args.value(QStringLiteral("width")); + const double width = widthValue.toDouble(-1); + const auto rgb = args.value(QStringLiteral("color")).toArray(); + if (!styleValue.isDouble() || !std::isfinite(style) || style < 0 || style > 5 || std::floor(style) != style + || !widthValue.isDouble() || !std::isfinite(width) || width <= 0 || width > 100 + || rgb.size() != 3 || !args.value(QStringLiteral("visible")).isBool()) + throw std::runtime_error("Explicit valid style, width, RGB color and visibility are required"); + for (const auto& channel : rgb) + if (!channel.isDouble() || !std::isfinite(channel.toDouble()) || channel.toDouble() < 0 || channel.toDouble() > 1) + throw std::runtime_error("Color channels must be in [0,1]"); + const Base::Color color(rgb[0].toDouble(), rgb[1].toDouble(), rgb[2].toDouble()); + std::vector points; + std::vector geometry; + for (const auto& selected : selection) { + const std::string sub = selected.toString().toStdString(); + const std::string type = TechDraw::DrawUtil::getGeomTypeFromName(sub); + const int index = TechDraw::DrawUtil::getIndexFromName(sub); + if (circle || centerCircle || arc || threePointCircle) { + if (type != "Vertex") throw std::runtime_error("Circle selection must contain vertices"); + auto vertex = view->getProjVertexByIndex(index); + if (!vertex) throw std::runtime_error("Selected projected vertex does not exist"); + const auto point = vertex->point(); + if (!std::isfinite(point.x) || !std::isfinite(point.y) || !std::isfinite(point.z)) + throw std::runtime_error("Selected vertex has invalid coordinates"); + points.push_back(point); + } else { + if (type != "Edge") throw std::runtime_error("Thread selection must contain circular edges"); + auto edge = view->getGeomByIndex(index); + if (!edge || edge->getGeomType() != TechDraw::GeomType::CIRCLE) + throw std::runtime_error("Selected edge must be a circle"); + auto source = std::static_pointer_cast(edge); + const auto center = TechDraw::CosmeticVertex::makeCanonicalPointInverted(view, source->center); + const double radius = source->radius * (hole ? 1.176 : 0.85) / scale; + if (!std::isfinite(radius) || radius <= 0) throw std::runtime_error("Invalid selected circle radius"); + geometry.push_back(std::make_shared(center, radius, 255.0, 165.0)); + } + } + if (centerCircle) { + if (points.size() != 1) + throw std::runtime_error("Select exactly one center vertex"); + const auto radiusValue = args.value(QStringLiteral("radius")); + const double radius = radiusValue.toDouble(-1); + if (!radiusValue.isDouble() || !std::isfinite(radius) || radius <= 0) + throw std::runtime_error("radius must be a finite positive model-space length"); + const auto center = TechDraw::CosmeticVertex::makeCanonicalPointInverted(view, points[0]); + geometry.push_back(std::make_shared(center, radius)); + } + if (circle) { + if (points.size() < 2) throw std::runtime_error("Select a center vertex and a radius vertex"); + const double radius = (points[1] - points[0]).Length() / scale; + if (!std::isfinite(radius) || radius <= 0) throw std::runtime_error("Circle vertices must be distinct"); + const auto center = TechDraw::CosmeticVertex::makeCanonicalPointInverted(view, points[0]); + geometry.push_back(std::make_shared(center, radius)); + } + if (arc || threePointCircle) { + if (points.size() < 3) throw std::runtime_error("Select at least three projected vertices"); + if (threePointCircle) { + const auto u = points[1] - points[0]; + const auto v = points[2] - points[0]; + const double determinant = u.x * v.y - u.y * v.x; + const double normProduct = u.Length() * v.Length(); + if (!std::isfinite(normProduct) || normProduct <= 0 + || std::abs(determinant) <= 1e-12 * normProduct) + throw std::runtime_error("Circle vertices must be distinct and non-collinear"); + const double u2 = u.x * u.x + u.y * u.y; + const double v2 = v.x * v.x + v.y * v.y; + const Base::Vector3d projectedCenter( + points[0].x + (u2 * v.y - v2 * u.y) / (2 * determinant), + points[0].y + (u.x * v2 - v.x * u2) / (2 * determinant), points[0].z); + const double radius = (points[0] - projectedCenter).Length() / scale; + const auto center = TechDraw::CosmeticVertex::makeCanonicalPointInverted(view, projectedCenter); + if (!std::isfinite(radius) || radius <= 0 || !std::isfinite(center.x) + || !std::isfinite(center.y) || !std::isfinite(center.z)) + throw std::runtime_error("Invalid three-point circle geometry"); + geometry.push_back(std::make_shared(center, radius)); + } else { + // Match the desktop command: calculate angles in non-inverted canonical coordinates. + const auto center = TechDraw::CosmeticVertex::makeCanonicalPoint(view, TechDraw::DrawUtil::invertY(points[0])); + const auto start = TechDraw::CosmeticVertex::makeCanonicalPoint(view, TechDraw::DrawUtil::invertY(points[1])); + const auto end = TechDraw::CosmeticVertex::makeCanonicalPoint(view, TechDraw::DrawUtil::invertY(points[2])); + const auto a = start - center; + const auto b = end - center; + const double radius = a.Length(); + if (!std::isfinite(radius) || radius <= 0 || !std::isfinite(b.Length()) || b.Length() <= 0) + throw std::runtime_error("Arc endpoints must differ from its center"); + auto angle = [](const Base::Vector3d& direction) { + const double degrees = std::atan2(direction.y, direction.x) * 180.0 / std::acos(-1.0); + return degrees < 0 ? degrees + 360.0 : degrees; + }; + auto curve = std::make_shared(center, radius, angle(a), angle(b)); + geometry.push_back(curve->inverted()); + } + } + document->openTransaction(name.toUtf8().constData()); + transaction = true; + QJsonArray tags; + for (const auto& geom : geometry) { + const auto tag = view->addCosmeticEdge(geom); + auto* edge = view->getCosmeticEdge(tag); + if (!edge) throw std::runtime_error("Cosmetic edge creation failed"); + edge->m_format.setStyle(static_cast(style)); + edge->m_format.setLineNumber(static_cast(style)); + edge->m_format.setWidth(width); + edge->m_format.setColor(color); + edge->m_format.setVisible(args.value(QStringLiteral("visible")).toBool()); + tags.append(QString::fromStdString(tag)); + } + view->refreshCEGeoms(); + view->requestPaint(); + document->commitTransaction(); + transaction = false; + QJsonObject typed; + typed.insert(QStringLiteral("document"), documentName); + typed.insert(QStringLiteral("view"), viewName); + typed.insert(QStringLiteral("cosmeticEdgeTags"), tags); + result.insert(QStringLiteral("result"), typed); + if (name == QStringLiteral("TechDraw_ExtensionDrawCosmArc")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_ExtensionDrawCosmCircle3Points")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_CosmeticCircle")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_ExtensionDrawCosmCircle")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_ExtensionThreadHoleBottom")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_ExtensionThreadBoltBottom")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command")); + } catch (const Base::Exception& error) { fail(error.what()); } + catch (const std::exception& error) { fail(error.what()); } + return true; +} +} + +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_PageTemplate") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_PageDefault") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_RedrawPage") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_Annotation") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionDecreaseDecimal") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionIncreaseDecimal") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionIncreaseDecreaseGroup") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionInsertDiameter") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionInsertPrefixGroup") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionInsertRepetition") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionInsertSquare") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionRemovePrefixChar") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionPosChainDimensionGroup") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionPosHorizChainDimension") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionPosVertChainDimension") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionPosObliqueChainDimension") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionCustomizeFormat") +static bool dispatchExtensionDimensions(const QString& name, const QJsonObject& input, + QJsonObject& result) +{ + if (name == QStringLiteral("TechDraw_ExtensionPositionSectionView")) { + result.insert(QStringLiteral("parameterSchema"), QStringLiteral( + "{document:string,selection:string[],mode?:\"orthogonal\"}")); + QJsonObject args = input.value(QStringLiteral("args")).toObject(); + if (args.value(QStringLiteral("args")).isObject()) + args = args.value(QStringLiteral("args")).toObject(); + App::Document* document = nullptr; + bool transaction = false; + try { + if (input.value(QStringLiteral("apiId")).toString() != QStringLiteral("TechDraw:desktop:") + name) + throw std::runtime_error("Desktop command ID/name mismatch"); + const QString documentName = args.value(QStringLiteral("document")).toString(); + if (documentName.isEmpty()) throw std::runtime_error("document handle is required"); + document = App::GetApplication().getDocument(documentName.toUtf8().constData()); + if (!document) throw std::runtime_error("Document does not exist"); + const auto selection = args.value(QStringLiteral("selection")).toArray(); + if (selection.size() != 1 + || args.value(QStringLiteral("mode")).toString(QStringLiteral("orthogonal")) != QStringLiteral("orthogonal")) { + result.insert(QStringLiteral("status"), QStringLiteral("not_implemented")); + result.insert(QStringLiteral("code"), QStringLiteral("implementation_required")); + result.insert(QStringLiteral("message"), QStringLiteral( + "Section edge/base vertex alignment requires native selection geometry and scale transformation; orthogonal mode requires one section view")); + return true; + } + if (!selection.at(0).isString() || selection.at(0).toString().isEmpty()) + throw std::runtime_error("A section view handle is required"); + auto* section = dynamic_cast( + document->getObject(selection.at(0).toString().toUtf8().constData())); + if (!section) throw std::runtime_error("Select a DrawViewSection in the current document"); + auto* base = dynamic_cast(section->BaseView.getValue()); + if (!base || base->getDocument() != document) + throw std::runtime_error("Section has no base view in the current document"); + // The official command positions against the projection group for a group item. + if (std::string(base->getTypeId().getName()) == "TechDraw::DrawProjGroupItem") { + const auto& parents = base->getInList(); + if (parents.empty()) throw std::runtime_error("Projection group item has no parent"); + base = dynamic_cast(parents.front()); + if (!base || base->getDocument() != document) + throw std::runtime_error("Invalid projection group parent"); + } + const double x = section->X.getValue(); + const double y = section->Y.getValue(); + const double baseX = base->X.getValue(); + const double baseY = base->Y.getValue(); + if (!std::isfinite(x) || !std::isfinite(y) + || !std::isfinite(baseX) || !std::isfinite(baseY)) + throw std::runtime_error("View positions must be finite"); + document->openTransaction("Position Section View"); + transaction = true; + if (std::abs(x - baseX) > std::abs(y - baseY)) + section->Y.setValue(baseY); + else + section->X.setValue(baseX); + document->recompute(); + if (section->isError()) throw std::runtime_error("Section recomputation failed"); + result.insert(QStringLiteral("result"), QJsonObject{ + {QStringLiteral("document"), QString::fromUtf8(document->getName())}, + {QStringLiteral("object"), QString::fromUtf8(section->getNameInDocument())}, + {QStringLiteral("baseView"), QString::fromUtf8(base->getNameInDocument())}, + {QStringLiteral("x"), section->X.getValue()}, + {QStringLiteral("y"), section->Y.getValue()}}); + document->commitTransaction(); + transaction = false; + result.insert(QStringLiteral("status"), QStringLiteral("success")); + } catch (const Base::Exception& error) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } catch (const std::exception& error) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } + return true; + } + if (name == QStringLiteral("TechDraw_FillTemplateFields")) { + result.insert(QStringLiteral("parameterSchema"), QStringLiteral( + "{document:string,page:string,fields?:{[field:string]:string}}")); + QJsonObject args = input.value(QStringLiteral("args")).toObject(); + if (args.value(QStringLiteral("args")).isObject()) + args = args.value(QStringLiteral("args")).toObject(); + App::Document* document = nullptr; + bool transaction = false; + try { + if (input.value(QStringLiteral("apiId")).toString() != QStringLiteral("TechDraw:desktop:") + name) + throw std::runtime_error("Desktop command ID/name mismatch"); + const QString documentName = args.value(QStringLiteral("document")).toString(); + const QString pageName = args.value(QStringLiteral("page")).toString(); + if (documentName.isEmpty() || pageName.isEmpty()) + throw std::runtime_error("Current document and page handles are required"); + document = App::GetApplication().getDocument(documentName.toUtf8().constData()); + if (!document) throw std::runtime_error("Document does not exist"); + auto* page = dynamic_cast( + document->getObject(pageName.toUtf8().constData())); + if (!page) throw std::runtime_error("Select a DrawPage in the current document"); + auto* svg = dynamic_cast(page->Template.getValue()); + if (!svg || svg->App::DocumentObject::getDocument() != document) + throw std::runtime_error("Page requires an SVG template in the current document"); + auto values = svg->EditableTexts.getValues(); + if (args.contains(QStringLiteral("fields"))) { + if (!args.value(QStringLiteral("fields")).isObject()) + throw std::runtime_error("fields must map existing editable field names to strings"); + const auto fields = args.value(QStringLiteral("fields")).toObject(); + for (auto it = fields.constBegin(); it != fields.constEnd(); ++it) { + const auto key = it.key().toUtf8().toStdString(); + auto field = values.find(key); + if (field == values.end()) + throw std::runtime_error("Unknown editable template field: " + key); + if (!it.value().isString()) + throw std::runtime_error("Template field values must be strings"); + field->second = it.value().toString().toUtf8().toStdString(); + } + if (!fields.isEmpty()) { + document->openTransaction("Fill Template Fields"); + transaction = true; + svg->EditableTexts.setValues(values); + document->recompute(); + if (svg->isError() || page->isError()) + throw std::runtime_error("Template or page recomputation failed"); + } + } + QJsonObject fields; + for (const auto& field : svg->EditableTexts.getValues()) + fields.insert(QString::fromUtf8(field.first.c_str()), + QString::fromUtf8(field.second.c_str())); + result.insert(QStringLiteral("result"), QJsonObject{ + {QStringLiteral("document"), QString::fromUtf8(document->getName())}, + {QStringLiteral("page"), QString::fromUtf8(page->getNameInDocument())}, + {QStringLiteral("template"), QString::fromUtf8(svg->getNameInDocument())}, + {QStringLiteral("fields"), fields}}); + if (transaction) { + document->commitTransaction(); + transaction = false; + } + result.insert(QStringLiteral("status"), QStringLiteral("success")); + } catch (const Base::Exception& error) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } catch (const std::exception& error) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } + return true; + } + const struct { const char* command; const char* dependency; } pending[] = { + {"TaskDimension", "TaskDimension native property/reference submission, accept/cancel transaction and Web task panel"}, + {"TechDraw_AxoLengthDimension", "Official axonometric selected-edge/vertex measurement and dimension creation"}, + {"TechDraw_HorizontalExtentDimension", "Official execExtent DistanceX geometry and reference construction"}, + {"TechDraw_VerticalExtentDimension", "Official execExtent DistanceY geometry and reference construction"}, + {"TechDraw_ExtensionThreadsGroup", "Complete iMsg dispatch: hole side (0), hole bottom (1), bolt side (2), bolt bottom (3); native paired-line side construction remains required"} + }; + for (const auto& entry : pending) { + if (name != QString::fromLatin1(entry.command)) continue; + result.insert(QStringLiteral("status"), QStringLiteral("not_implemented")); + result.insert(QStringLiteral("code"), QStringLiteral("implementation_required")); + result.insert(QStringLiteral("message"), QString::fromLatin1(entry.dependency)); + return true; + } + const QString prefix = QStringLiteral("TechDraw_Extension"); + const QString operation = name.mid(prefix.size()); + const QStringList commands = {QStringLiteral("DecreaseDecimal"), QStringLiteral("IncreaseDecimal"), + QStringLiteral("IncreaseDecreaseGroup"), QStringLiteral("InsertDiameter"), + QStringLiteral("InsertPrefixGroup"), QStringLiteral("InsertRepetition"), + QStringLiteral("InsertSquare"), QStringLiteral("RemovePrefixChar"), + QStringLiteral("PosChainDimensionGroup"), QStringLiteral("PosHorizChainDimension"), + QStringLiteral("PosVertChainDimension"), QStringLiteral("PosObliqueChainDimension")}; + if (name == prefix + QStringLiteral("CustomizeFormat")) { + result.insert(QStringLiteral("parameterSchema"), QStringLiteral( + "{document:string,selection:string[],action?:\"read\"|\"accept\"|\"cancel\",format?:string}")); + QJsonObject args = input.value(QStringLiteral("args")).toObject(); + if (args.value(QStringLiteral("args")).isObject()) + args = args.value(QStringLiteral("args")).toObject(); + App::Document* document = nullptr; + bool transaction = false; + try { + if (input.value(QStringLiteral("apiId")).toString() != QStringLiteral("TechDraw:desktop:") + name) + throw std::runtime_error("Desktop command ID/name mismatch"); + const auto actionValue = args.value(QStringLiteral("action")); + if (!actionValue.isUndefined() && !actionValue.isString()) + throw std::runtime_error("action must be read, accept or cancel"); + const QString action = actionValue.toString(args.contains(QStringLiteral("format")) + ? QStringLiteral("accept") : QStringLiteral("read")); + if (action != QStringLiteral("read") && action != QStringLiteral("accept") + && action != QStringLiteral("cancel")) + throw std::runtime_error("action must be read, accept or cancel"); + const QString documentName = args.value(QStringLiteral("document")).toString(); + if (documentName.isEmpty()) throw std::runtime_error("document handle is required"); + document = App::GetApplication().getDocument(documentName.toUtf8().constData()); + if (!document) throw std::runtime_error("Document does not exist"); + const auto selection = args.value(QStringLiteral("selection")).toArray(); + if (selection.isEmpty() || !selection.at(0).isString() + || selection.at(0).toString().isEmpty()) + throw std::runtime_error("Select a dimension or balloon in the current document"); + auto* object = document->getObject(selection.at(0).toString().toUtf8().constData()); + auto* dimension = dynamic_cast(object); + auto* balloon = dynamic_cast(object); + if (!dimension && !balloon) + throw std::runtime_error("The first selected object must be a dimension or balloon"); + if (action == QStringLiteral("accept")) { + if (!args.value(QStringLiteral("format")).isString()) + throw std::runtime_error("format text is required for accept"); + const QString format = args.value(QStringLiteral("format")).toString(); + if (format.contains(QChar(0))) + throw std::runtime_error("format must not contain null characters"); + const std::string text = format.toUtf8().toStdString(); + document->openTransaction("Customize Format"); + transaction = true; + if (dimension) dimension->FormatSpec.setValue(text); + else balloon->Text.setValue(text); + document->recompute(); + if (object->isError()) throw std::runtime_error("Formatted object recomputation failed"); + } + QJsonObject state{ + {QStringLiteral("document"), QString::fromUtf8(document->getName())}, + {QStringLiteral("object"), QString::fromUtf8(object->getNameInDocument())}, + {QStringLiteral("property"), dimension ? QStringLiteral("FormatSpec") : QStringLiteral("Text")}, + {QStringLiteral("format"), QString::fromUtf8(dimension + ? dimension->FormatSpec.getValue() : balloon->Text.getValue())}, + {QStringLiteral("action"), action}}; + if (dimension) { + const double measuredValue = dimension->getDimValue(); + if (!std::isfinite(measuredValue)) + throw std::runtime_error("Dimension measurement is not finite"); + state.insert(QStringLiteral("value"), measuredValue); + } + if (transaction) { + document->commitTransaction(); + transaction = false; + } + result.insert(QStringLiteral("result"), state); + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } catch (const Base::Exception& error) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } catch (const std::exception& error) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } + return true; + } + if (!name.startsWith(prefix) || !commands.contains(operation)) return false; + QJsonObject args = input.value(QStringLiteral("args")).toObject(); + if (args.value(QStringLiteral("args")).isObject()) args = args.value(QStringLiteral("args")).toObject(); + result.insert(QStringLiteral("parameterSchema"), QStringLiteral( + "{document:string,selection:string[],iMsg?:integer,repeatCount?:string}")); + App::Document* document = nullptr; + bool transaction = false; + auto fail = [&](const char* message) { + if (transaction) { document->abortTransaction(); transaction = false; } + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(message)); + }; + try { + if (input.value(QStringLiteral("apiId")).toString() != QStringLiteral("TechDraw:desktop:") + name) + throw std::runtime_error("Desktop command ID/name mismatch"); + QString action = operation; + if (operation.endsWith(QStringLiteral("Group"))) { + const auto mode = args.value(QStringLiteral("iMsg")); + const int count = operation == QStringLiteral("InsertPrefixGroup") ? 4 + : operation == QStringLiteral("IncreaseDecreaseGroup") ? 2 : 3; + if (!mode.isDouble() || mode.toDouble() != mode.toInt(-1) || mode.toInt(-1) < 0 || mode.toInt() >= count) + throw std::runtime_error("iMsg must select a valid command group action"); + const QStringList choices = operation == QStringLiteral("InsertPrefixGroup") + ? QStringList{QStringLiteral("InsertDiameter"), QStringLiteral("InsertSquare"), + QStringLiteral("InsertRepetition"), QStringLiteral("RemovePrefixChar")} + : operation == QStringLiteral("IncreaseDecreaseGroup") + ? QStringList{QStringLiteral("IncreaseDecimal"), QStringLiteral("DecreaseDecimal")} + : QStringList{QStringLiteral("PosHorizChainDimension"), QStringLiteral("PosVertChainDimension"), + QStringLiteral("PosObliqueChainDimension")}; + action = choices.at(mode.toInt()); + } + const QString documentName = args.value(QStringLiteral("document")).toString(); + if (documentName.isEmpty()) throw std::runtime_error("document handle is required"); + document = App::GetApplication().getDocument(documentName.toUtf8().constData()); + if (!document) throw std::runtime_error("Document does not exist"); + const bool horizontal = action == QStringLiteral("PosHorizChainDimension"); + const bool vertical = action == QStringLiteral("PosVertChainDimension"); + const bool oblique = action == QStringLiteral("PosObliqueChainDimension"); + const bool chain = horizontal || vertical || oblique; + const QString type = horizontal ? QStringLiteral("DistanceX") : vertical + ? QStringLiteral("DistanceY") : QStringLiteral("Distance"); + const auto selection = args.value(QStringLiteral("selection")).toArray(); + if (selection.isEmpty()) throw std::runtime_error("Select dimension objects in the current document"); + std::vector dimensions; + for (const auto& handle : selection) { + if (!handle.isString() || handle.toString().isEmpty()) throw std::runtime_error("Invalid selection handle"); + auto* object = document->getObject(handle.toString().toUtf8().constData()); + if (!object) throw std::runtime_error("Selected object does not exist in the current document"); + auto* dimension = dynamic_cast(object); + if (dimension && (!chain || QString::fromLatin1(dimension->Type.getValueAsString()) == type)) + dimensions.push_back(dimension); + } + if (dimensions.empty()) throw std::runtime_error("No dimensions of the required type selected"); + QString insertion; + if (action == QStringLiteral("InsertDiameter")) insertion = QChar(0x2300); + if (action == QStringLiteral("InsertSquare")) insertion = QChar(0x25a1); + if (action == QStringLiteral("InsertRepetition")) { + if (!args.value(QStringLiteral("repeatCount")).isString()) + throw std::runtime_error("repeatCount text is required"); + insertion = args.value(QStringLiteral("repeatCount")).toString() + QChar(0x00d7) + QLatin1Char(' '); + } + document->openTransaction(name.toUtf8().constData()); + transaction = true; + const double masterX = dimensions.front()->X.getValue(); + const double masterY = dimensions.front()->Y.getValue(); + Base::Vector3d direction; + if (oblique) { + const auto points = dimensions.front()->getLinearPoints(); + direction = points.second() - points.first(); + direction.y = -direction.y; + if (direction.Length() == 0.0) throw std::runtime_error("Degenerate master dimension"); + } + for (auto* dimension : dimensions) { + if (chain) { + if (oblique) { + const auto position = TechDraw::DrawUtil::getTrianglePoint( + Base::Vector3d(masterX, masterY, 0.0), direction, + Base::Vector3d(dimension->X.getValue(), dimension->Y.getValue(), 0.0)); + if (!std::isfinite(position.x) || !std::isfinite(position.y)) + throw std::runtime_error("Invalid chain position"); + dimension->X.setValue(position.x); + dimension->Y.setValue(position.y); + } else { + const auto points = dimension->getLinearPoints(); + if (horizontal) { + dimension->Y.setValue(masterY); + dimension->X.setValue((points.first().x + points.second().x) / 2.0); + } else { + dimension->X.setValue(masterX); + dimension->Y.setValue((points.first().y + points.second().y) / -2.0 + + 0.5 * TechDraw::Preferences::dimFontSizeMM()); + } + } + } else { + std::string format = dimension->FormatSpec.getStrValue(); + if (action == QStringLiteral("RemovePrefixChar")) { + const auto position = format.find("%."); + if (position == std::string::npos) throw std::runtime_error("FormatSpec has no precision placeholder"); + format = format.substr(position); + } else if (action == QStringLiteral("IncreaseDecimal") || action == QStringLiteral("DecreaseDecimal")) { + const auto position = format.find("%."); + if (position == std::string::npos || position + 2 >= format.size() + || format[position + 2] < '0' || format[position + 2] > '9') + throw std::runtime_error("FormatSpec has no valid decimal precision"); + const int precision = format[position + 2] - '0' + + (action == QStringLiteral("IncreaseDecimal") ? 1 : -1); + if (precision >= 0 && precision <= 9) format[position + 2] = char('0' + precision); + } else { + format.insert(0, insertion.toUtf8().constData()); + } + dimension->FormatSpec.setValue(format); + } + } + document->recompute(); + QJsonArray states; + for (auto* dimension : dimensions) { + if (dimension->isError()) throw std::runtime_error("Native dimension recomputation failed"); + QJsonObject state; + state.insert(QStringLiteral("object"), QString::fromUtf8(dimension->getNameInDocument())); + state.insert(QStringLiteral("formatSpec"), QString::fromUtf8(dimension->FormatSpec.getValue())); + state.insert(QStringLiteral("x"), dimension->X.getValue()); + state.insert(QStringLiteral("y"), dimension->Y.getValue()); + states.append(state); + } + document->commitTransaction(); + transaction = false; + QJsonObject state; + state.insert(QStringLiteral("document"), documentName); + state.insert(QStringLiteral("dimensions"), states); + result.insert(QStringLiteral("result"), state); + if (name == QStringLiteral("TechDraw_ExtensionDecreaseDecimal")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_ExtensionIncreaseDecimal")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_ExtensionIncreaseDecreaseGroup")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_ExtensionInsertDiameter")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_ExtensionInsertPrefixGroup")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_ExtensionInsertRepetition")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_ExtensionInsertSquare")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_ExtensionPosChainDimensionGroup")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_ExtensionPosHorizChainDimension")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_ExtensionPosObliqueChainDimension")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_ExtensionPosVertChainDimension")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_ExtensionRemovePrefixChar")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } + } catch (const Base::Exception& error) { fail(error.what()); } + catch (const std::exception& error) { fail(error.what()); } + return true; +} + +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionChangeLineAttributes") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionCircleCenterLines") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionSelectLineAttributes") +static bool dispatchSelectedLineExtensions(const QString& name, const QJsonObject& input, + QJsonObject& result) +{ + const bool circles = name == QStringLiteral("TechDraw_ExtensionCircleCenterLines"); + const bool selectAttributes = name == QStringLiteral("TechDraw_ExtensionSelectLineAttributes"); + if (!circles && !selectAttributes && name != QStringLiteral("TechDraw_ExtensionChangeLineAttributes")) return false; + QJsonObject args = input.value(QStringLiteral("args")).toObject(); + if (args.value(QStringLiteral("args")).isObject()) args = args.value(QStringLiteral("args")).toObject(); + result.insert(QStringLiteral("parameterSchema"), QStringLiteral( + "{document:string,view:string,selection:string[],style?:integer,width?:number,color?:[r:number,g:number,b:number],visible?:boolean,lineNumber?:integer}")); + if (selectAttributes) + result.insert(QStringLiteral("parameterSchema"), QStringLiteral( + "{style?:integer,width?:number,color?:[r:number,g:number,b:number],visible?:boolean,lineNumber?:integer}")); + App::Document* document = nullptr; + bool transaction = false; + try { + if (input.value(QStringLiteral("apiId")).toString() != QStringLiteral("TechDraw:desktop:") + name) + throw std::runtime_error("Desktop command ID/name mismatch"); + if (args.value(QStringLiteral("taskActive")).toBool()) + throw std::runtime_error("Finish the active task first"); + auto format = TechDraw::LineFormat::getCurrentLineFormat(); + auto number = [&](const char* key, double low, double high, bool integral) { + const auto value = args.value(QString::fromLatin1(key)); + const double n = value.toDouble(-1); + if (!value.isDouble() || !std::isfinite(n) || n < low || n > high + || (integral && std::floor(n) != n)) + throw std::runtime_error("Invalid line attribute numeric value"); + return n; + }; + if (args.contains(QStringLiteral("style"))) format.setStyle(static_cast(number("style", 0, 5, true))); + if (args.contains(QStringLiteral("width"))) format.setWidth(number("width", 0.000001, 100, false)); + if (args.contains(QStringLiteral("lineNumber"))) format.setLineNumber(static_cast(number("lineNumber", 0, 2147483647, true))); + if (args.contains(QStringLiteral("visible"))) { + if (!args.value(QStringLiteral("visible")).isBool()) throw std::runtime_error("visible must be boolean"); + format.setVisible(args.value(QStringLiteral("visible")).toBool()); + } + if (args.contains(QStringLiteral("color"))) { + const auto rgb = args.value(QStringLiteral("color")).toArray(); + if (rgb.size() != 3) throw std::runtime_error("color requires three components"); + for (const auto component : rgb) + if (!component.isDouble() || !std::isfinite(component.toDouble()) + || component.toDouble() < 0 || component.toDouble() > 1) + throw std::runtime_error("color components must be in [0,1]"); + format.setColor(Base::Color(rgb[0].toDouble(), rgb[1].toDouble(), rgb[2].toDouble())); + } + if (name == QStringLiteral("TechDraw_ExtensionSelectLineAttributes")) { + TechDraw::LineFormat::getCurrentLineFormat() = format; + const auto& current = TechDraw::LineFormat::getCurrentLineFormat(); + result.insert(QStringLiteral("result"), QJsonObject{ + {QStringLiteral("style"), current.getStyle()}, + {QStringLiteral("width"), current.getWidth()}, + {QStringLiteral("visible"), current.getVisible()}, + {QStringLiteral("lineNumber"), current.getLineNumber()}}); + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command")); + return true; + } + const QString documentName = args.value(QStringLiteral("document")).toString(); + const QString viewName = args.value(QStringLiteral("view")).toString(); + if (documentName.isEmpty() || viewName.isEmpty()) + throw std::runtime_error("document and view handles are required"); + document = App::GetApplication().getDocument(documentName.toUtf8().constData()); + if (!document) throw std::runtime_error("Document does not exist"); + auto* view = dynamic_cast(document->getObject(viewName.toUtf8().constData())); + if (!view || !view->findParentPage()) throw std::runtime_error("Select a DrawViewPart on a page"); + const double scale = view->getScale(); + if (!std::isfinite(scale) || scale <= 0) throw std::runtime_error("Invalid view scale"); + const auto selection = args.value(QStringLiteral("selection")).toArray(); + if (selection.isEmpty()) throw std::runtime_error("Select projected edges"); + std::vector targets; + std::vector> circleGeometry; + QStringList seen; + for (const auto item : selection) { + const QString sub = item.toString(); + bool valid = false; + const int index = sub.mid(4).toInt(&valid); + if (!sub.startsWith(QStringLiteral("Edge")) || !valid || index <= 0) + throw std::runtime_error("Selection must contain projected Edge names"); + if (seen.contains(sub)) continue; + seen.append(sub); + auto geom = view->getGeomByIndex(index - 1); + if (!geom) throw std::runtime_error("Selected edge does not exist"); + if (circles) { + if (geom->getGeomType() != TechDraw::GeomType::CIRCLE && geom->getGeomType() != TechDraw::GeomType::ARCOFCIRCLE) + continue; + auto circle = std::static_pointer_cast(geom); + const auto center = TechDraw::CosmeticVertex::makeCanonicalPointInverted(view, circle->center); + const double radius = circle->radius / scale; + if (!std::isfinite(center.x) || !std::isfinite(center.y) || !std::isfinite(radius) || radius <= 0) + throw std::runtime_error("Invalid selected circle geometry"); + circleGeometry.emplace_back(center, radius); + } else if (geom->getCosmetic()) { + if (geom->source() == TechDraw::SourceType::COSMETICEDGE) { + auto* edge = view->getCosmeticEdgeBySelection(sub.toStdString()); + if (!edge) throw std::runtime_error("Cosmetic edge could not be resolved"); + targets.push_back(&edge->m_format); + } else if (geom->source() == TechDraw::SourceType::CENTERLINE) { + auto* line = view->getCenterLineBySelection(sub.toStdString()); + if (!line) throw std::runtime_error("Centerline could not be resolved"); + targets.push_back(&line->m_format); + } + } + } + if (circles ? circleGeometry.empty() : targets.empty()) + throw std::runtime_error("Selection contains no applicable edges"); + document->openTransaction(name.toUtf8().constData()); + transaction = true; + QJsonArray created; + for (const auto& entry : circleGeometry) { + const auto& c = entry.first; + const double extent = entry.second + 2.0; + const std::string horizontal = view->addCosmeticEdge( + Base::Vector3d(c.x + extent, c.y, 0), Base::Vector3d(c.x - extent, c.y, 0)); + const std::string vertical = view->addCosmeticEdge( + Base::Vector3d(c.x, c.y + extent, 0), Base::Vector3d(c.x, c.y - extent, 0)); + for (const auto& tag : {horizontal, vertical}) { + auto* edge = view->getCosmeticEdge(tag); + if (!edge) throw std::runtime_error("Created centerline could not be resolved"); + targets.push_back(&edge->m_format); + created.append(QString::fromStdString(tag)); + } + } + QJsonArray attributes; + for (auto* target : targets) { + target->setStyle(format.getStyle()); + target->setWidth(format.getWidth()); + target->setColor(format.getColor()); + target->setVisible(format.getVisible()); + target->setLineNumber(circles ? TechDraw::Preferences::CenterLineStyle() : format.getLineNumber()); + attributes.append(QJsonObject{{QStringLiteral("style"), target->getStyle()}, + {QStringLiteral("width"), target->getWidth()}, + {QStringLiteral("visible"), target->getVisible()}, + {QStringLiteral("lineNumber"), target->getLineNumber()}}); + } + view->refreshCEGeoms(); + view->requestPaint(); + document->commitTransaction(); + transaction = false; + TechDraw::LineFormat::getCurrentLineFormat() = format; + if (name == QStringLiteral("TechDraw_ExtensionChangeLineAttributes")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_ExtensionCircleCenterLines")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } + result.insert(QStringLiteral("result"), QJsonObject{{QStringLiteral("document"), documentName}, + {QStringLiteral("view"), viewName}, {QStringLiteral("createdEdges"), created}, + {QStringLiteral("lineAttributes"), attributes}}); + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command")); + } catch (const Base::Exception& error) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } catch (const std::exception& error) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } + return true; +} + +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionArcLengthAnnotation") +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionAreaAnnotation") +static bool dispatchMeasuredAnnotation(const QString& name, const QJsonObject& input, + QJsonObject& result) +{ + const bool area = name == QStringLiteral("TechDraw_ExtensionAreaAnnotation"); + if (!area && name != QStringLiteral("TechDraw_ExtensionArcLengthAnnotation")) return false; + QJsonObject args = input.value(QStringLiteral("args")).toObject(); + if (args.value(QStringLiteral("args")).isObject()) args = args.value(QStringLiteral("args")).toObject(); + result.insert(QStringLiteral("parameterSchema"), QStringLiteral( + "{document:string,view:string,selection:string[],x?:number,y?:number}")); + App::Document* document = nullptr; + bool transaction = false; + try { + if (input.value(QStringLiteral("apiId")).toString() != QStringLiteral("TechDraw:desktop:") + name) + throw std::runtime_error("Desktop command ID/name mismatch"); + if (args.value(QStringLiteral("taskActive")).toBool()) throw std::runtime_error("Finish the active task first"); + const QString documentName = args.value(QStringLiteral("document")).toString(); + const QString viewName = args.value(QStringLiteral("view")).toString(); + if (documentName.isEmpty() || viewName.isEmpty()) throw std::runtime_error("document and view handles are required"); + document = App::GetApplication().getDocument(documentName.toUtf8().constData()); + if (!document) throw std::runtime_error("Document does not exist"); + auto* view = dynamic_cast(document->getObject(viewName.toUtf8().constData())); + if (!view) throw std::runtime_error("view must identify a DrawViewPart"); + auto* page = view->findParentPage(); + if (!page) throw std::runtime_error("The view must belong to a page"); + const double scale = view->getScale(); + if (!std::isfinite(scale) || scale <= 0) throw std::runtime_error("Invalid view scale"); + const auto selection = args.value(QStringLiteral("selection")).toArray(); + QStringList selected; + std::vector edges; + std::vector lengths; + double total = 0; + Base::Vector3d center; + for (const auto item : selection) { + const QString sub = item.toString(); + const QString prefix = area ? QStringLiteral("Face") : QStringLiteral("Edge"); + bool valid = false; + const int index = sub.mid(4).toInt(&valid); + if (!sub.startsWith(prefix) || !valid || index <= 0) + throw std::runtime_error("Selection must contain projected faces for area or edges for length"); + if (selected.contains(sub)) continue; + selected.append(sub); + GProp_GProps props; + if (area) { + auto face = view->getFace(sub.toStdString()); + if (!face) throw std::runtime_error("Selected projected face does not exist"); + BRepGProp::SurfaceProperties(face->toOccFace(), props); + const gp_Pnt point = props.CentreOfMass(); + center += Base::Vector3d(point.X(), point.Y(), point.Z()) * props.Mass(); + } else { + auto edge = view->getEdge(sub.toStdString()); + if (!edge) throw std::runtime_error("Selected projected edge does not exist"); + edges.push_back(edge->getOCCEdge()); + BRepGProp::LinearProperties(edges.back(), props); + lengths.push_back(props.Mass()); + } + if (!std::isfinite(props.Mass()) || props.Mass() < 0) throw std::runtime_error("Invalid geometric measure"); + total += props.Mass(); + } + if (selected.isEmpty() || !std::isfinite(total) || total <= Precision::Confusion()) + throw std::runtime_error("Selection has no positive geometric measure"); + if (area) { + center /= total; + } else { + double remaining = total * 0.5; + size_t index = 0; + while (index + 1 < lengths.size() && remaining > lengths[index]) { + remaining -= lengths[index++]; + } + BRepAdaptor_Curve curve(edges[index]); + gp_Pnt point; + curve.D0(curve.LastParameter(), point); + GCPnts_AbscissaPoint abscissa(Precision::Confusion(), curve, remaining, curve.FirstParameter()); + if (abscissa.IsDone()) curve.D0(abscissa.Parameter(), point); + center = Base::Vector3d(point.X(), point.Y(), point.Z()); + } + const Base::Vector3d anchor = TechDraw::DrawUtil::invertY(center / scale); + const double measure = total / (area ? scale * scale : scale); + if (!std::isfinite(measure) || !std::isfinite(anchor.x) || !std::isfinite(anchor.y)) + throw std::runtime_error("Invalid annotation measure or anchor"); + QString text; + if (area) { + Base::Quantity quantity; + quantity.setValue(measure); + quantity.setUnit(Base::Unit::Area); + text = QString::fromStdString(quantity.getUserString()); + if (text.endsWith(QStringLiteral("^2"))) { + text.chop(2); + text.append(QChar(0x00b2)); + } + } else { + TechDraw::DrawViewDimension helper; + using Format = TechDraw::DimensionFormatter::Format; + text = QString(QChar(0x25e0)) + QStringLiteral(" ") + QString::fromStdString( + helper.formatValue(measure, QString::fromUtf8(helper.FormatSpec.getStrValue().data()), + helper.isMultiValueSchema() ? Format::UNALTERED : Format::FORMATTED)); + } + auto position = [&](const char* key, double fallback) { + const auto value = args.value(QString::fromLatin1(key)); + if (value.isUndefined()) return fallback; + if (!value.isDouble() || !std::isfinite(value.toDouble())) throw std::runtime_error("Annotation coordinates must be finite numbers"); + return value.toDouble(); + }; + const double x = position("x", anchor.x + (area ? 0.0 : 20.0 / scale)); + const double y = position("y", anchor.y + (area ? 0.0 : 20.0 / scale)); + document->openTransaction(name.toUtf8().constData()); + transaction = true; + auto* balloon = dynamic_cast(document->addObject("TechDraw::DrawViewBalloon", "Balloon")); + if (!balloon) throw std::runtime_error("Could not create annotation balloon"); + balloon->SourceView.setValue(view); + page->addView(balloon); + balloon->Text.setValue(text.toStdString()); + balloon->OriginX.setValue(anchor.x); + balloon->OriginY.setValue(anchor.y); + balloon->X.setValue(x); + balloon->Y.setValue(y); + if (area) { + balloon->BubbleShape.setValue("Rectangle"); + balloon->EndType.setValue("None"); + balloon->KinkLength.setValue(0.0); + balloon->ScaleType.setValue("Page"); + } else { + balloon->BubbleShape.setValue("Line"); + balloon->EndType.setValue(TechDraw::Preferences::getPreferenceGroup("Dimensions")->GetInt("ArrowStyle", 0)); + } + view->touch(true); + document->recompute(); + QJsonObject state{{QStringLiteral("document"), documentName}, {QStringLiteral("view"), viewName}, + {QStringLiteral("balloon"), QString::fromUtf8(balloon->getNameInDocument())}, + {QStringLiteral("measure"), measure}, {QStringLiteral("unit"), area ? QStringLiteral("mm^2") : QStringLiteral("mm")}, + {QStringLiteral("text"), QString::fromUtf8(balloon->Text.getValue())}, + {QStringLiteral("x"), balloon->X.getValue()}, {QStringLiteral("y"), balloon->Y.getValue()}, + {QStringLiteral("originX"), balloon->OriginX.getValue()}, {QStringLiteral("originY"), balloon->OriginY.getValue()}}; + document->commitTransaction(); + transaction = false; + result.insert(QStringLiteral("result"), state); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command")); + if (name == QStringLiteral("TechDraw_ExtensionArcLengthAnnotation")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_ExtensionAreaAnnotation")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } + } catch (const Base::Exception& error) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } catch (const std::exception& error) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } + return true; +} + +// FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionHoleCircle") +static bool dispatchHoleCircle(const QString& name, const QJsonObject& input, + QJsonObject& result) +{ + if (name != QStringLiteral("TechDraw_ExtensionHoleCircle")) return false; + QJsonObject args = input.value(QStringLiteral("args")).toObject(); + if (args.value(QStringLiteral("args")).isObject()) args = args.value(QStringLiteral("args")).toObject(); + result.insert(QStringLiteral("parameterSchema"), QStringLiteral( + "{document:string,view:string,selection:string[],style:integer,width:number,color:[r:number,g:number,b:number],visible:boolean}")); + App::Document* document = nullptr; + bool transaction = false; + try { + if (input.value(QStringLiteral("apiId")).toString() != QStringLiteral("TechDraw:desktop:") + name) + throw std::runtime_error("Desktop command ID/name mismatch"); + const QString documentName = args.value(QStringLiteral("document")).toString(); + const QString viewName = args.value(QStringLiteral("view")).toString(); + if (documentName.isEmpty() || viewName.isEmpty()) throw std::runtime_error("document and view handles are required"); + document = App::GetApplication().getDocument(documentName.toUtf8().constData()); + if (!document) throw std::runtime_error("Document does not exist"); + auto* view = dynamic_cast(document->getObject(viewName.toUtf8().constData())); + if (!view) throw std::runtime_error("view must identify a DrawViewPart"); + const double scale = view->getScale(); + if (!std::isfinite(scale) || scale <= 0) throw std::runtime_error("Invalid view scale"); + const auto styleValue = args.value(QStringLiteral("style")); + const double style = styleValue.toDouble(-1); + const auto widthValue = args.value(QStringLiteral("width")); + const double width = widthValue.toDouble(-1); + const auto rgb = args.value(QStringLiteral("color")).toArray(); + if (!styleValue.isDouble() || !std::isfinite(style) || style < 0 || style > 5 || std::floor(style) != style + || !widthValue.isDouble() || !std::isfinite(width) || width <= 0 || width > 100 + || rgb.size() != 3 || !args.value(QStringLiteral("visible")).isBool()) + throw std::runtime_error("Explicit valid line style, width, RGB color and visibility are required"); + for (const auto& channel : rgb) + if (!channel.isDouble() || !std::isfinite(channel.toDouble()) || channel.toDouble() < 0 || channel.toDouble() > 1) + throw std::runtime_error("Color channels must be in [0,1]"); + std::vector circles; + for (const auto& selected : args.value(QStringLiteral("selection")).toArray()) { + const QString sub = selected.toString(); + bool valid = false; + const int index = sub.mid(4).toInt(&valid); + if (!sub.startsWith(QStringLiteral("Edge")) || !valid || index <= 0) + throw std::runtime_error("Selection must contain projected Edge names"); + const auto geom = view->getGeomByIndex(index - 1); + if (!geom) throw std::runtime_error("Selected projected edge does not exist"); + if (geom->getGeomType() == TechDraw::GeomType::CIRCLE || geom->getGeomType() == TechDraw::GeomType::ARCOFCIRCLE) + circles.push_back(std::static_pointer_cast(geom)); + } + if (circles.size() < 3) throw std::runtime_error("Fewer than three circles selected"); + const auto p = circles[0]->center; + const auto u = circles[1]->center - p; + const auto v = circles[2]->center - p; + const double determinant = u.x * v.y - u.y * v.x; + const double norm = u.Length() * v.Length(); + if (!std::isfinite(norm) || norm <= 0 || std::abs(determinant) <= 1e-12 * norm) + throw std::runtime_error("The first three circle centers must be distinct and non-collinear"); + const double u2 = u.x * u.x + u.y * u.y; + const double v2 = v.x * v.x + v.y * v.y; + const Base::Vector3d projected(p.x + (u2 * v.y - v2 * u.y) / (2 * determinant), + p.y + (u.x * v2 - v.x * u2) / (2 * determinant), p.z); + const auto center = TechDraw::CosmeticVertex::makeCanonicalPointInverted(view, projected); + const double radius = (p - projected).Length() / scale; + if (!std::isfinite(radius) || radius <= 0 || !std::isfinite(center.x) + || !std::isfinite(center.y) || !std::isfinite(center.z)) + throw std::runtime_error("Invalid bolt circle geometry"); + std::vector> lines; + for (const auto& circle : circles) { + const auto point = TechDraw::CosmeticVertex::makeCanonicalPointInverted(view, circle->center); + auto delta = point - center; + const double length = delta.Length(); + const double extension = circle->radius / scale * 1.1; + if (!std::isfinite(length) || length <= 0 || !std::isfinite(extension) || extension <= 0) + throw std::runtime_error("Invalid radial centerline geometry"); + delta = delta * (extension / length); + lines.emplace_back(point + delta, point - delta); + } + document->openTransaction(name.toUtf8().constData()); + transaction = true; + QJsonArray tags; + auto format = [&](const std::string& tag) { + auto* edge = view->getCosmeticEdge(tag); + if (!edge) throw std::runtime_error("Cosmetic edge creation failed"); + edge->m_format.setStyle(static_cast(style)); + edge->m_format.setLineNumber(static_cast(style)); + edge->m_format.setWidth(width); + edge->m_format.setColor(Base::Color(rgb[0].toDouble(), rgb[1].toDouble(), rgb[2].toDouble())); + edge->m_format.setVisible(args.value(QStringLiteral("visible")).toBool()); + tags.append(QString::fromStdString(tag)); + }; + format(view->addCosmeticEdge(std::make_shared(center, radius))); + for (const auto& line : lines) format(view->addCosmeticEdge(line.first, line.second)); + view->refreshCEGeoms(); + view->requestPaint(); + document->commitTransaction(); + transaction = false; + result.insert(QStringLiteral("result"), QJsonObject{ + {QStringLiteral("document"), documentName}, {QStringLiteral("view"), viewName}, + {QStringLiteral("cosmeticEdgeTags"), tags}, + {QStringLiteral("center"), QJsonArray{center.x, center.y, center.z}}, + {QStringLiteral("radius"), radius}}); + if (name == QStringLiteral("TechDraw_ExtensionHoleCircle")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command")); + } catch (const Base::Exception& error) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } catch (const std::exception& error) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } + return true; +} + +bool dispatchTechDrawDesktopCommand(const QString& name, const QJsonObject& input, + QJsonObject& result) +{ + if (name == QStringLiteral("TechDraw_Image")) { + QJsonObject args = input.value(QStringLiteral("args")).toObject(); + if (args.value(QStringLiteral("args")).isObject()) + args = args.value(QStringLiteral("args")).toObject(); + result.insert(QStringLiteral("parameterSchema"), QStringLiteral( + "{document:string,page:string,imagePath?:string,imageBase64?:string,owner?:string} (provide exactly one image source)")); + App::Document* document = nullptr; + bool transaction = false; + QTemporaryFile uploadedImage; + try { + if (input.value(QStringLiteral("apiId")).toString() != QStringLiteral("TechDraw:desktop:TechDraw_Image")) + throw std::runtime_error("Desktop command ID/name mismatch"); + const QString documentName = args.value(QStringLiteral("document")).toString(); + const QString pageName = args.value(QStringLiteral("page")).toString(); + QString imagePath = args.value(QStringLiteral("imagePath")).toString(); + if (documentName.isEmpty() || pageName.isEmpty()) + throw std::runtime_error("document and page are required"); + document = App::GetApplication().getDocument(documentName.toUtf8().constData()); + if (!document) throw std::runtime_error("Document does not exist"); + auto* page = dynamic_cast(document->getObject(pageName.toUtf8().constData())); + if (!page) throw std::runtime_error("Selected page does not exist or is not a DrawPage"); + const bool upload = args.contains(QStringLiteral("imageBase64")); + if (upload == args.contains(QStringLiteral("imagePath"))) + throw std::runtime_error("Provide exactly one of imagePath or imageBase64"); + if (upload) { + const auto encodedValue = args.value(QStringLiteral("imageBase64")); + if (!encodedValue.isString()) throw std::runtime_error("imageBase64 must be a string"); + const QString encoded = encodedValue.toString(); + if (encoded.isEmpty() || encoded.size() > 64 * 1024 * 1024) + throw std::runtime_error("imageBase64 must contain at most 64 MiB of base64 text"); + const QByteArray bytes = QByteArray::fromBase64(encoded.toLatin1()); + if (bytes.isEmpty() || QString::fromLatin1(bytes.toBase64()) != encoded) + throw std::runtime_error("imageBase64 must be canonical padded base64 without a data URL prefix"); + if (!uploadedImage.open() || uploadedImage.write(bytes) != bytes.size() || !uploadedImage.flush()) + throw std::runtime_error("Cannot stage uploaded image in the module filesystem"); + imagePath = uploadedImage.fileName(); + } + const QFileInfo file(imagePath); + if (!file.isFile() || !file.isReadable() || file.size() == 0) + throw std::runtime_error("imagePath must identify a nonempty readable image in the module filesystem"); + TechDraw::DrawView* owner = nullptr; + if (args.contains(QStringLiteral("owner"))) { + const QString ownerName = args.value(QStringLiteral("owner")).toString(); + if (ownerName.isEmpty()) throw std::runtime_error("owner must be a view handle"); + owner = dynamic_cast(document->getObject(ownerName.toUtf8().constData())); + if (!owner || owner->findParentPage() != page) + throw std::runtime_error("Owner must be a view on the selected page"); + } + document->openTransaction("Create Image"); + transaction = true; + auto* view = document->addObject("Image"); + if (!view) throw std::runtime_error("Image creation failed"); + view->translateLabel("DrawViewImage", "Image", view->getNameInDocument()); + const auto filespec = TechDraw::DrawUtil::cleanFilespecBackslash(imagePath.toUtf8().toStdString()); + view->ImageFile.setValue(filespec.c_str()); + const QString storedPath = QString::fromUtf8(view->ImageFile.getValue()); + QFile storedImage(storedPath); + if (!storedImage.open(QIODevice::ReadOnly)) + throw std::runtime_error("Native image property did not retain a readable file"); + const QByteArray storedBytes = storedImage.readAll(); + if (storedBytes.isEmpty() || storedImage.error() != QFileDevice::NoError) + throw std::runtime_error("Cannot read the image retained by the native document"); + storedImage.close(); + if (owner) view->Owner.setValue(owner); + page->addView(view); + document->recompute(); + QJsonObject state{ + {QStringLiteral("document"), QString::fromUtf8(document->getName())}, + {QStringLiteral("page"), QString::fromUtf8(page->getNameInDocument())}, + {QStringLiteral("object"), QString::fromUtf8(view->getNameInDocument())}, + {QStringLiteral("imageBase64"), QString::fromLatin1(storedBytes.toBase64())}, + {QStringLiteral("owner"), owner ? QString::fromUtf8(owner->getNameInDocument()) : QString()} + }; + document->commitTransaction(); + transaction = false; + result.insert(QStringLiteral("status"), QStringLiteral("success")); + result.insert(QStringLiteral("result"), state); + // PropertyFileIncluded normally copies the input; retain staging if the property still refers to it. + if (upload && QFileInfo(storedPath).absoluteFilePath() == QFileInfo(uploadedImage.fileName()).absoluteFilePath()) + uploadedImage.setAutoRemove(false); + // Keep this command unmarked until the browser page consumes the native image result. + result.insert(QStringLiteral("implementationBoundary"), QStringLiteral( + "Native insertion accepts module filesystem images or base64 bytes and returns retained image bytes; browser file selection and page image rendering remain required")); + } catch (const std::exception& error) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } + return true; + } + if (name == QStringLiteral("TechDraw_Hatch") || name == QStringLiteral("TechDraw_GeometricHatch")) { + QJsonObject args = input.value(QStringLiteral("args")).toObject(); + if (args.value(QStringLiteral("args")).isObject()) + args = args.value(QStringLiteral("args")).toObject(); + result.insert(QStringLiteral("parameterSchema"), QStringLiteral( + "{document:string,view:string,selection:string[],replaceExisting?:boolean}")); + App::Document* document = nullptr; + bool transaction = false; + try { + if (input.value(QStringLiteral("apiId")).toString() != QStringLiteral("TechDraw:desktop:") + name) + throw std::runtime_error("Desktop command ID/name mismatch"); + const QString documentName = args.value(QStringLiteral("document")).toString(); + const QString viewName = args.value(QStringLiteral("view")).toString(); + if (documentName.isEmpty() || viewName.isEmpty()) + throw std::runtime_error("document and view handles are required"); + document = App::GetApplication().getDocument(documentName.toUtf8().constData()); + if (!document) throw std::runtime_error("Document does not exist"); + auto* view = dynamic_cast(document->getObject(viewName.toUtf8().constData())); + if (!view || !view->findParentPage()) + throw std::runtime_error("Select a DrawViewPart belonging to a drawing page"); + const auto selection = args.value(QStringLiteral("selection")); + if (!selection.isArray() || selection.toArray().isEmpty()) + throw std::runtime_error("Select at least one projected face"); + QStringList faces; + std::vector subNames; + std::vector faceIndices; + for (const auto& selected : selection.toArray()) { + const QString face = selected.toString(); + bool valid = false; + const int index = face.mid(4).toInt(&valid); + if (!selected.isString() || !face.startsWith(QStringLiteral("Face")) + || !valid || index <= 0 || face != QStringLiteral("Face") + QString::number(index)) + throw std::runtime_error("Selection must contain projected Face names"); + if (!faces.contains(face)) { + faces.append(face); + subNames.push_back(face.toStdString()); + faceIndices.push_back(index); + } + } + const bool geometric = name == QStringLiteral("TechDraw_GeometricHatch"); + const auto hatches = view->getHatches(); + bool replacement = false; + if (!geometric) { + for (const int face : faceIndices) + replacement = replacement || TechDraw::DrawHatch::faceIsHatched(face, hatches); + if (args.contains(QStringLiteral("replaceExisting")) + && !args.value(QStringLiteral("replaceExisting")).isBool()) + throw std::runtime_error("replaceExisting must be boolean"); + if (replacement && !args.value(QStringLiteral("replaceExisting")).toBool()) { + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("hatch_replacement_confirmation_required")); + result.insert(QStringLiteral("message"), QStringLiteral("Selected faces already have hatches; submit replaceExisting=true to replace them")); + return true; + } + } + document->openTransaction(geometric ? "Create GeomHatch" : "Create Hatch"); + transaction = true; + if (replacement) { + for (auto* hatch : hatches) { + // Remove selected references before checking emptiness, so deleted objects are never revisited. + bool changed = false; + for (const auto& sub : hatch->Source.getSubValues()) { + if (faces.contains(QString::fromStdString(sub))) { + hatch->removeSub(TechDraw::DrawUtil::getIndexFromName(sub)); + changed = true; + } + } + if (changed && hatch->empty()) document->removeObject(hatch->getNameInDocument()); + } + } + App::DocumentObject* created = nullptr; + if (geometric) { + auto* hatch = document->addObject("GeomHatch"); + if (!hatch) throw std::runtime_error("Geometric hatch creation failed"); + hatch->Source.setValue(view, subNames); + hatch->translateLabel("DrawGeomHatch", "GeomHatch", hatch->getNameInDocument()); + created = hatch; + } else { + auto* hatch = document->addObject("Hatch"); + if (!hatch) throw std::runtime_error("Hatch creation failed"); + hatch->Source.setValue(view, subNames); + created = hatch; + } + view->touch(); + document->recompute(); + const QJsonObject state{ + {QStringLiteral("document"), QString::fromUtf8(document->getName())}, + {QStringLiteral("view"), QString::fromUtf8(view->getNameInDocument())}, + {QStringLiteral("page"), QString::fromUtf8(view->findParentPage()->getNameInDocument())}, + {QStringLiteral("object"), QString::fromUtf8(created->getNameInDocument())} + }; + document->commitTransaction(); + transaction = false; + result.insert(QStringLiteral("status"), QStringLiteral("success")); + result.insert(QStringLiteral("result"), state); + result.insert(QStringLiteral("implementationBoundary"), QStringLiteral( + "Native source object creation only; projected face existence validation, hatch task pattern properties and browser hatch rendering remain required")); + } catch (const std::exception& error) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } + return true; + } + if (dispatchHoleCircle(name, input, result)) return true; + if (name == QStringLiteral("TechDraw_StackTop") || name == QStringLiteral("TechDraw_StackBottom") + || name == QStringLiteral("TechDraw_StackUp") || name == QStringLiteral("TechDraw_StackDown") + || name == QStringLiteral("TechDraw_StackGroup")) { + // Native state changes are not a completed command until the page renderer consumes them. + result.insert(QStringLiteral("implementationBoundary"), QJsonObject { + {QStringLiteral("capabilityId"), QStringLiteral("TechDraw:desktop:") + name}, + {QStringLiteral("nativeStateProperty"), QStringLiteral("DrawPage.WebStackState")}, + {QStringLiteral("integrationStatus"), QStringLiteral("implementation_required")}, + {QStringLiteral("affectedCapabilities"), QJsonArray { + QStringLiteral("TechDraw:desktop:TechDraw_StackBottom"), + QStringLiteral("TechDraw:desktop:TechDraw_StackDown"), + QStringLiteral("TechDraw:desktop:TechDraw_StackGroup"), + QStringLiteral("TechDraw:desktop:TechDraw_StackTop") + }}, + {QStringLiteral("existingFacade"), QStringLiteral("web/src/techdraw-python-family-facade.ts")}, + {QStringLiteral("rendererDispatch"), QStringLiteral("dispatchTechDrawStackForRenderer")}, + {QStringLiteral("applicationCommandHook"), QStringLiteral("TechDrawStackPage.renderCommands(dispatcher, commands)")}, + {QStringLiteral("reusableRendererAdapters"), QJsonArray { + QStringLiteral("createTechDrawSvgStackRenderer"), + QStringLiteral("bindTechDrawStackRenderer"), + QStringLiteral("useTechDrawStackDispatcher"), + QStringLiteral("TechDrawStackPage"), + QStringLiteral("TechDrawStackCommandControls") + }}, + {QStringLiteral("missingIntegrationAnchor"), QStringLiteral( + "Application call site must mount TechDrawStackPage with live renderer state, generated registry commands and Worker transport, and connect DesktopCommandDispatcher execution to renderCommands dispatcher.execute; current packet does not supply this call site's editable anchors")}, + {QStringLiteral("requiredEditableAnchors"), QJsonArray { + QStringLiteral("Application TechDraw page mount: connect existing TechDrawStackPage to the live item registry and item resolver"), + QStringLiteral("Current document/page selection and active task state providers"), + QStringLiteral("DesktopCommandDispatcher execution callback: forward the generated registry cmd and original args to the page-owned renderCommands dispatcher.execute; supply the existing Worker transport to the page renderer"), + QStringLiteral("Page load, undo/redo and item lifecycle subscriptions with effect cleanup") + }}, + {QStringLiteral("requiredRendererContract"), QJsonArray { + QStringLiteral("Supply current document, page, selection and activeTask"), + QStringLiteral("Supply live initialViewState including renderer parents and membership"), + QStringLiteral("Apply native viewState to rendered sibling stacking after applyNative"), + QStringLiteral("Restore via readNative after page load, undo, redo and view lifecycle changes"), + QStringLiteral("Include non-page graphics child items in sibling ordering") + }} + }); + result.insert(QStringLiteral("parameterSchema"), QStringLiteral( + "{document:string,page:string,selection:string[],activeTask:boolean,iMsg?:0|1|2|3," + "action?:\"applyNative\"|\"readNative\",initialViewState?:{itemId:{stackOrder:integer,parent:string,rendered:boolean,graphicsItem?:boolean}}}")); + QJsonObject args = input.value(QStringLiteral("args")).toObject(); + if (args.value(QStringLiteral("args")).isObject()) + args = args.value(QStringLiteral("args")).toObject(); + // Ordinary desktop requests mutate; restoration is an explicit renderer operation. + if (!args.contains(QStringLiteral("action"))) + args.insert(QStringLiteral("action"), QStringLiteral("applyNative")); + const auto requestedAction = args.value(QStringLiteral("action")); + if (!requestedAction.isString() + || (requestedAction.toString() != QStringLiteral("applyNative") + && requestedAction.toString() != QStringLiteral("readNative"))) { + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("invalid_stack_action")); + result.insert(QStringLiteral("message"), QStringLiteral("action must be applyNative or readNative")); + return true; + } + // Page restoration only reads ordering; desktop task exclusion applies to mutations. + const bool restoringStack = requestedAction.toString() == QStringLiteral("readNative"); + if (!restoringStack && !args.value(QStringLiteral("activeTask")).isBool()) { + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("missing_task_state")); + result.insert(QStringLiteral("message"), QStringLiteral( + "The page task controller must supply the current activeTask state")); + return true; + } + if (args.value(QStringLiteral("activeTask")).toBool() && !restoringStack) { + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("active_task")); + result.insert(QStringLiteral("message"), QStringLiteral( + "Close the active task dialog and try again.")); + return true; + } + if (name == QStringLiteral("TechDraw_StackGroup") && !restoringStack) { + const QJsonValue mode = args.value(QStringLiteral("iMsg")); + if (!mode.isDouble() || mode.toDouble() < 0 || mode.toDouble() > 3 + || mode.toDouble() != mode.toInt(-1)) { + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("invalid_stack_mode")); + result.insert(QStringLiteral("message"), QStringLiteral( + "iMsg must be an integer: 0=top, 1=bottom, 2=up, 3=down")); + return true; + } + } + const QString stackAction = args.value(QStringLiteral("action")).toString(QStringLiteral("applyNative")); + if (stackAction != QStringLiteral("applyNative") && stackAction != QStringLiteral("readNative")) { + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("invalid_stack_action")); + result.insert(QStringLiteral("message"), QStringLiteral( + "action must be applyNative or readNative")); + return true; + } + if (!args.value(QStringLiteral("initialViewState")).isObject()) { + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("missing_renderer_state")); + result.insert(QStringLiteral("message"), QStringLiteral( + "Every stack request requires current renderer membership, parents and visibility")); + return true; + } + if (stackAction == QStringLiteral("applyNative") || stackAction == QStringLiteral("readNative")) { + App::Document* document = nullptr; + bool transaction = false; + try { + if (input.value(QStringLiteral("apiId")).toString() + != QStringLiteral("TechDraw:desktop:") + name) + throw std::runtime_error("Desktop command does not match apiId"); + const QString documentName = args.value(QStringLiteral("document")).toString(); + if (documentName.isEmpty()) throw std::runtime_error("document is required"); + document = App::GetApplication().getDocument(documentName.toUtf8().constData()); + if (!document) throw std::runtime_error("Document does not exist"); + auto* page = dynamic_cast(document->getObject( + args.value(QStringLiteral("page")).toString().toUtf8().constData())); + if (!page) throw std::runtime_error("page must identify a DrawPage in the document"); + auto* property = page->getPropertyByName("WebStackState"); + auto* stored = dynamic_cast(property); + if (property && !stored) throw std::runtime_error("WebStackState property type conflict"); + QJsonObject state; + if (stored) { + const auto parsed = QJsonDocument::fromJson(QByteArray(stored->getValue())); + if (!parsed.isObject()) throw std::runtime_error("Invalid persisted WebStackState"); + state = parsed.object(); + } else if (stackAction == QStringLiteral("applyNative")) { + state = args.value(QStringLiteral("initialViewState")).toObject(); + } + const auto pageViews = page->Views.getValues(); + // Renderer membership and visibility are live; persisted values only own z order. + if (args.value(QStringLiteral("initialViewState")).isObject()) { + QJsonObject current = args.value(QStringLiteral("initialViewState")).toObject(); + for (auto it = current.begin(); it != current.end(); ++it) { + if (!it.value().isObject()) + throw std::runtime_error("Invalid renderer view state"); + auto entry = it.value().toObject(); + // Validate live input before persisted ordering can replace its values. + const auto liveOrder = entry.value(QStringLiteral("stackOrder")); + const auto liveGraphicsItem = entry.value(QStringLiteral("graphicsItem")); + if (!liveOrder.isDouble() + || liveOrder.toDouble() < std::numeric_limits::min() + || liveOrder.toDouble() > std::numeric_limits::max() + || liveOrder.toDouble() != liveOrder.toInt() + || !entry.value(QStringLiteral("parent")).isString() + || !entry.value(QStringLiteral("rendered")).isBool() + || (!liveGraphicsItem.isUndefined() && !liveGraphicsItem.isBool())) + throw std::runtime_error("Invalid renderer view state"); + const auto previous = state.value(it.key()).toObject(); + // Graphics-only items belong to the live renderer, not persisted document state. + if (stored && !entry.value(QStringLiteral("graphicsItem")).toBool() + && !previous.value(QStringLiteral("graphicsItem")).toBool() + && entry.value(QStringLiteral("parent")).isString() + && entry.value(QStringLiteral("parent")) == previous.value(QStringLiteral("parent")) + && previous.contains(QStringLiteral("stackOrder"))) { + // Mutations must start from the ordering visible to the user. + // readNative reconciles persisted ordering after undo or reload. + if (stackAction == QStringLiteral("applyNative") + && liveOrder != previous.value(QStringLiteral("stackOrder"))) { + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("stale_view_state")); + result.insert(QStringLiteral("message"), QStringLiteral( + "Restore the current page with readNative before changing its stacking order")); + result.insert(QStringLiteral("document"), documentName); + result.insert(QStringLiteral("page"), QString::fromUtf8(page->getNameInDocument())); + return true; + } + entry.insert(QStringLiteral("stackOrder"), previous.value(QStringLiteral("stackOrder"))); + } + it.value() = entry; + } + state = current; + } + for (auto it = state.begin(); it != state.end(); ++it) { + auto* object = document->getObject(it.key().toUtf8().constData()); + bool onPage = false; + for (auto* view : pageViews) if (view == object) onPage = true; + const auto entry = it.value().toObject(); + const auto graphicsItem = entry.value(QStringLiteral("graphicsItem")); + if (!graphicsItem.isUndefined() && !graphicsItem.isBool()) + throw std::runtime_error("graphicsItem must be a boolean"); + if (graphicsItem.toBool()) { + if (it.key().isEmpty() || object) + throw std::runtime_error("Graphics item IDs must be nonempty and distinct from document objects"); + } else { + if (!onPage) throw std::runtime_error("View state contains an object outside the page"); + if (!dynamic_cast(object)) + throw std::runtime_error("View state must identify DrawView objects"); + } + const auto z = entry.value(QStringLiteral("stackOrder")); + if (!it.value().isObject() || !z.isDouble() + || z.toDouble() < std::numeric_limits::min() + || z.toDouble() > std::numeric_limits::max() + || z.toDouble() != z.toInt() + || !entry.value(QStringLiteral("parent")).isString() + || !entry.value(QStringLiteral("rendered")).isBool()) + throw std::runtime_error("Invalid renderer view state"); + QString ancestor = entry.value(QStringLiteral("parent")).toString(); + int remaining = state.size(); + while (!ancestor.isEmpty()) { + if (ancestor == it.key() || --remaining < 0) + throw std::runtime_error("Cyclic renderer view parent hierarchy"); + if (!state.contains(ancestor) || !state.value(ancestor).isObject()) + throw std::runtime_error("Renderer parent must identify an item in initialViewState"); + ancestor = state.value(ancestor).toObject().value(QStringLiteral("parent")).toString(); + } + } + for (auto* view : pageViews) { + if (!view || !state.contains(QString::fromUtf8(view->getNameInDocument()))) + throw std::runtime_error("Initial view state must cover every page view"); + } + if (stackAction == QStringLiteral("readNative")) { + result.insert(QStringLiteral("nativeApplied"), false); + result.insert(QStringLiteral("nativeRead"), true); + result.insert(QStringLiteral("hasStoredViewState"), stored != nullptr); + result.insert(QStringLiteral("action"), stackAction); + result.insert(QStringLiteral("previousViewState"), + args.value(QStringLiteral("initialViewState")).toObject()); + result.insert(QStringLiteral("viewState"), state); + result.insert(QStringLiteral("document"), documentName); + result.insert(QStringLiteral("page"), QString::fromUtf8(page->getNameInDocument())); + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + result.insert(QStringLiteral("code"), QStringLiteral("native_stack_read")); + result.insert(QStringLiteral("message"), QStringLiteral( + "Native stacking reconciled with current page views; apply viewState to the mounted renderer")); + return true; + } + if (!args.value(QStringLiteral("selection")).isArray()) + throw std::runtime_error("selection must be an array of view names"); + if (args.value(QStringLiteral("selection")).toArray().isEmpty()) + throw std::runtime_error("Select at least one DrawView to change its stacking order"); + if (name == QStringLiteral("TechDraw_StackGroup")) { + const QJsonValue message = args.value(QStringLiteral("iMsg")); + if (!message.isDouble() || message.toDouble() < 0.0 + || message.toDouble() > 3.0 + || message.toDouble() != static_cast(message.toInt(-1))) + throw std::runtime_error("StackGroup iMsg must be an integer from 0 to 3"); + } + const int mode = name == QStringLiteral("TechDraw_StackGroup") + ? args.value(QStringLiteral("iMsg")).toInt() + : name == QStringLiteral("TechDraw_StackTop") ? 0 + : name == QStringLiteral("TechDraw_StackBottom") ? 1 + : name == QStringLiteral("TechDraw_StackUp") ? 2 : 3; + QStringList selectedViews; + const QJsonObject previousViewState = state; + QJsonArray stackChanges; + bool changed = false; + const QJsonValue selection = args.value(QStringLiteral("selection")); + if (!selection.isArray()) { + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("invalid_stack_selection")); + result.insert(QStringLiteral("message"), QStringLiteral( + "selection must be an array of current page DrawView object names")); + return true; + } + for (const auto selected : selection.toArray()) { + if (!selected.isString() || !state.contains(selected.toString())) + throw std::runtime_error("Selected view is not on the page"); + if (state.value(selected.toString()).toObject().value(QStringLiteral("graphicsItem")).toBool()) + throw std::runtime_error("Stack commands select DrawView objects, not graphics-only items"); + // Desktop selection resolves objects once, including selections of several subelements. + if (selectedViews.contains(selected.toString())) continue; + selectedViews.append(selected.toString()); + auto entry = state.value(selected.toString()).toObject(); + if (!entry.value(QStringLiteral("rendered")).toBool()) continue; + int z = entry.value(QStringLiteral("stackOrder")).toInt(); + if (mode < 2) { + for (auto it = state.begin(); it != state.end(); ++it) { + const auto sibling = it.value().toObject(); + if (!sibling.value(QStringLiteral("rendered")).toBool() + || sibling.value(QStringLiteral("parent")).toString() + != entry.value(QStringLiteral("parent")).toString()) continue; + const int peer = sibling.value(QStringLiteral("stackOrder")).toInt(); + if ((mode == 0 && peer > z) || (mode == 1 && peer < z)) z = peer; + } + } + const int step = mode == 0 || mode == 2 ? 1 : -1; + if ((step > 0 && z == std::numeric_limits::max()) + || (step < 0 && z == std::numeric_limits::min())) + throw std::runtime_error("StackOrder overflow"); + stackChanges.append(QJsonObject { + {QStringLiteral("view"), selected.toString()}, + {QStringLiteral("parent"), entry.value(QStringLiteral("parent"))}, + {QStringLiteral("previousStackOrder"), entry.value(QStringLiteral("stackOrder"))}, + {QStringLiteral("stackOrder"), z + step} + }); + entry.insert(QStringLiteral("stackOrder"), z + step); + state.insert(selected.toString(), entry); + changed = true; + } + if (changed) { + document->openTransaction("TechDraw web view stacking"); + transaction = true; + if (!stored) stored = dynamic_cast(page->addDynamicProperty( + "App::PropertyString", "WebStackState", "Web View")); + if (!stored) throw std::runtime_error("Cannot persist WebStackState"); + QJsonObject persistedViews; + for (auto it = state.begin(); it != state.end(); ++it) { + if (!it.value().toObject().value(QStringLiteral("graphicsItem")).toBool()) + persistedViews.insert(it.key(), it.value()); + } + stored->setValue(QJsonDocument(persistedViews).toJson(QJsonDocument::Compact).constData()); + document->commitTransaction(); + transaction = false; + } + result.insert(QStringLiteral("nativeApplied"), true); + result.insert(QStringLiteral("nativeRead"), false); + result.insert(QStringLiteral("documentChanged"), changed); + result.insert(QStringLiteral("action"), stackAction); + result.insert(QStringLiteral("previousViewState"), previousViewState); + result.insert(QStringLiteral("stackChanges"), stackChanges); + result.insert(QStringLiteral("viewState"), state); + result.insert(QStringLiteral("document"), documentName); + result.insert(QStringLiteral("page"), QString::fromUtf8(page->getNameInDocument())); + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + result.insert(QStringLiteral("code"), QStringLiteral("native_stack_applied")); + result.insert(QStringLiteral("message"), QStringLiteral( + "Native stacking persisted on the current page; apply viewState to the mounted renderer")); + return true; + } catch (const std::exception& error) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + return true; + } + } + result.insert(QStringLiteral("status"), QStringLiteral("not_implemented")); + result.insert(QStringLiteral("code"), QStringLiteral("implementation_required")); + result.insert(QStringLiteral("message"), QStringLiteral( + "Native page-view sibling stacking is available via action=applyNative and persisted on " + "the current DrawPage. React must supply initial renderer state, consume returned " + "viewState, and restore it on page load via action=readNative. The renderer must include " + "graphics-only siblings using graphicsItem=true and synchronize their lifecycle; " + "this is not a completed Web command.")); + return true; + } + if (dispatchMeasuredAnnotation(name, input, result)) return true; + if (dispatchSelectedLineExtensions(name, input, result)) return true; + if (dispatchExtensionDimensions(name, input, result)) return true; + if (dispatchDimensions(name, input, result)) return true; + if (dispatchAnnotationGeometry(name, input, result)) return true; + if (dispatchExtensionGeometry(name, input, result)) return true; + if (dispatchDocumentViews(name, input, result)) return true; + const bool defaultPage = name == QStringLiteral("TechDraw_PageDefault"); + const bool create = defaultPage || name == QStringLiteral("TechDraw_PageTemplate"); + const bool annotation = name == QStringLiteral("TechDraw_Annotation"); + if (!create && !annotation && name != QStringLiteral("TechDraw_RedrawPage")) return false; + QJsonObject args = input.value(QStringLiteral("args")).toObject(); + if (args.value(QStringLiteral("args")).isObject()) + args = args.value(QStringLiteral("args")).toObject(); + result.insert(QStringLiteral("parameterSchema"), defaultPage + ? QStringLiteral("{document:string}") : create + ? QStringLiteral("{document:string,templatePath:string}") + : annotation + ? QStringLiteral("{document:string,page:string,text:string[],owner?:string}") + : QStringLiteral("{document:string,page:string}")); + App::Document* document = nullptr; + bool transaction = false; + auto fail = [&](const char* message) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(message)); + }; + try { + if (input.value(QStringLiteral("apiId")).toString() != QStringLiteral("TechDraw:desktop:") + name) + throw std::runtime_error("Desktop command ID/name mismatch"); + const QString documentName = args.value(QStringLiteral("document")).toString(); + if (documentName.isEmpty()) throw std::runtime_error("document handle is required"); + document = App::GetApplication().getDocument(documentName.toUtf8().constData()); + if (!document) throw std::runtime_error("Document does not exist"); + TechDraw::DrawPage* page = nullptr; + TechDraw::DrawViewAnnotation* createdAnnotation = nullptr; + if (create) { + const QString path = defaultPage ? TechDraw::Preferences::defaultTemplate() + : args.value(QStringLiteral("templatePath")).toString(); + const QFileInfo file(path); + if (path.isEmpty() || !file.isFile() || !file.isReadable()) + throw std::runtime_error(defaultPage ? "No default template found in the module filesystem" + : "templatePath must identify a readable SVG asset in the module filesystem"); + document->openTransaction("Drawing create page"); + transaction = true; + page = document->addObject("Page"); + auto* svg = document->addObject("Template"); + if (!page || !svg) throw std::runtime_error("Page/template creation failed"); + page->translateLabel("DrawPage", "Page", page->getNameInDocument()); + svg->translateLabel("DrawSVGTemplate", "Template", svg->getNameInDocument()); + page->Template.setValue(svg); + const auto filespec = TechDraw::DrawUtil::cleanFilespecBackslash(path.toUtf8().toStdString()); + svg->Template.setValue(filespec); + document->recompute(); + if (!page->hasValidTemplate()) throw std::runtime_error("Invalid SVG page template"); + } else { + const QString pageName = args.value(QStringLiteral("page")).toString(); + if (pageName.isEmpty()) throw std::runtime_error("page handle is required"); + page = dynamic_cast(document->getObject(pageName.toUtf8().constData())); + if (!page) throw std::runtime_error("Selected page does not exist or is not a DrawPage"); + if (name == QStringLiteral("TechDraw_Annotation")) { + if (!args.value(QStringLiteral("text")).isArray()) + throw std::runtime_error("text must be an array of strings"); + std::vector lines; + for (const auto& line : args.value(QStringLiteral("text")).toArray()) { + if (!line.isString()) throw std::runtime_error("Each text line must be a string"); + lines.push_back(line.toString().toUtf8().toStdString()); + } + if (lines.empty()) throw std::runtime_error("At least one text line is required"); + TechDraw::DrawView* owner = nullptr; + if (args.contains(QStringLiteral("owner"))) { + const auto ownerValue = args.value(QStringLiteral("owner")); + if (!ownerValue.isString() || ownerValue.toString().isEmpty()) + throw std::runtime_error("owner must be a view handle"); + owner = dynamic_cast(document->getObject( + ownerValue.toString().toUtf8().constData())); + if (!owner) throw std::runtime_error("Owner does not exist or is not a DrawView"); + bool onPage = false; + for (auto* view : page->Views.getValues()) if (view == owner) onPage = true; + if (!onPage) throw std::runtime_error("Owner must belong to the selected page"); + } + document->openTransaction("Create Annotation"); + transaction = true; + createdAnnotation = document->addObject("Annotation"); + if (!createdAnnotation) throw std::runtime_error("Annotation creation failed"); + createdAnnotation->translateLabel("DrawViewAnnotation", "Annotation", + createdAnnotation->getNameInDocument()); + createdAnnotation->Text.setValues(lines); + if (owner) createdAnnotation->Owner.setValue(owner); + page->addView(createdAnnotation); + document->recompute(); + document->commitTransaction(); + transaction = false; + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else { + page->redrawCommand(); + } + } + QJsonObject state; + state.insert(QStringLiteral("document"), QString::fromUtf8(document->getName())); + state.insert(QStringLiteral("page"), QString::fromUtf8(page->getNameInDocument())); + state.insert(QStringLiteral("validTemplate"), page->hasValidTemplate()); + state.insert(QStringLiteral("scale"), page->Scale.getValue()); + QJsonArray views; + for (auto* view : page->Views.getValues()) + if (view) views.append(QString::fromUtf8(view->getNameInDocument())); + state.insert(QStringLiteral("views"), views); + if (createdAnnotation) { + state.insert(QStringLiteral("object"), QString::fromUtf8(createdAnnotation->getNameInDocument())); + QJsonArray text; + for (const auto& line : createdAnnotation->Text.getValues()) + text.append(QString::fromUtf8(line.c_str())); + state.insert(QStringLiteral("text"), text); + auto* owner = createdAnnotation->Owner.getValue(); + state.insert(QStringLiteral("owner"), owner + ? QJsonValue(QString::fromUtf8(owner->getNameInDocument())) : QJsonValue(QJsonValue::Null)); + } + if (auto* svg = dynamic_cast(page->Template.getValue())) { + state.insert(QStringLiteral("template"), QString::fromUtf8(svg->getNameInDocument())); + state.insert(QStringLiteral("width"), svg->getWidth()); + state.insert(QStringLiteral("height"), svg->getHeight()); + } + if (transaction) { document->commitTransaction(); transaction = false; } + result.insert(QStringLiteral("result"), state); + if (name == QStringLiteral("TechDraw_PageDefault")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_PageTemplate")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_RedrawPage")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } + } catch (const Base::Exception& error) { fail(error.what()); } + catch (const std::exception& error) { fail(error.what()); } + result.insert(QStringLiteral("resourcesReleased"), App::GetApplication().getDocuments().empty()); + return true; +} diff --git a/src/WebAssembly/FreeCADCoreProbe/TechDrawDesktopCommands.h b/src/WebAssembly/FreeCADCoreProbe/TechDrawDesktopCommands.h new file mode 100644 index 00000000..015a6d28 --- /dev/null +++ b/src/WebAssembly/FreeCADCoreProbe/TechDrawDesktopCommands.h @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#pragma once +#include +#include + +bool dispatchTechDrawDesktopCommand(const QString& name, const QJsonObject& input, + QJsonObject& result); diff --git a/src/WebAssembly/FreeCADCoreProbe/TechDrawRuntimeProbe.cpp b/src/WebAssembly/FreeCADCoreProbe/TechDrawRuntimeProbe.cpp index deb94c22..e9c3f76e 100644 --- a/src/WebAssembly/FreeCADCoreProbe/TechDrawRuntimeProbe.cpp +++ b/src/WebAssembly/FreeCADCoreProbe/TechDrawRuntimeProbe.cpp @@ -1,15 +1,19 @@ // SPDX-License-Identifier: LGPL-2.1-or-later #include +#include "TechDrawDesktopCommands.h" #include #include #include #include #include #include +#include #include +#include #include #include #include +#include #include #include #include @@ -19,10 +23,14 @@ #include #include #include +#include #include +#include +#include #include #include #include +#include #include #include @@ -290,6 +298,131 @@ bool callProjection(const char* name, PyObject* shape, int& bytes) return bytes > 0; } +QJsonObject createDrawing(const QJsonObject& args) +{ + App::Document* doc = nullptr; + bool created = false; + bool transaction = false; + const char* stage = "validate"; + QJsonObject result; + try { + if (args.value(QStringLiteral("schema")) != QStringLiteral("freecad-web-techdraw/frontend-drawing/v1") + || args.value(QStringLiteral("module")) != QStringLiteral("TechDraw") + || args.value(QStringLiteral("action")) != QStringLiteral("create/recompute")) + throw Base::ValueError("TechDraw drawing envelope is invalid"); + const auto idValue = args.value(QStringLiteral("documentId")); + const QString id = idValue.isUndefined() ? QStringLiteral("TechDrawDrawing") : idValue.toString(); + if (!QRegularExpression(QStringLiteral("^[A-Za-z_][A-Za-z0-9_]{0,63}$")).match(id).hasMatch()) + throw Base::ValueError("documentId must be a valid document name of at most 64 characters"); + const bool update = args.contains(QStringLiteral("box")); + const QJsonObject dimensions = args.value(QStringLiteral("box")).toObject(); + double sizes[3] = {}; + const char* keys[] = {"length", "width", "height"}; + if (update) { + for (int i = 0; i < 3; ++i) { + const auto value = dimensions.value(QString::fromLatin1(keys[i])); + sizes[i] = value.toDouble(); + if (!value.isDouble() || !std::isfinite(sizes[i]) || sizes[i] <= 0) + throw Base::ValueError("Box dimensions must be positive finite numbers"); + } + } + stage = "initialize"; + if (!ensureTechDraw()) throw Base::RuntimeError(errorMessage); + stage = "document"; + auto& app = App::GetApplication(); + doc = app.getDocument(id.toUtf8().constData()); + if (!doc) { + if (!update) throw Base::ValueError("Drawing document does not exist"); + doc = app.newDocument(id.toUtf8().constData(), id.toUtf8().constData(), {false, true}); + created = true; + } + if (!doc) throw Base::RuntimeError("Drawing document creation failed"); + doc->setUndoMode(1); + doc->openTransaction("Create or recompute drawing"); + transaction = true; + stage = "objects"; + auto object = [&](const char* type, const char* name) { + auto* existing = doc->getObject(name); + if (!created && !existing) throw Base::ValueError("Drawing document is missing its native objects"); + return existing ? existing : doc->addObject(type, name); + }; + auto* box = dynamic_cast(object("Part::Box", "Box")); + auto* page = dynamic_cast(object("TechDraw::DrawPage", "Page")); + auto* templ = dynamic_cast(object("TechDraw::DrawSVGTemplate", "Template")); + auto* view = dynamic_cast(object("TechDraw::DrawViewPart", "View")); + if (!box || !page || !templ || !view) throw Base::ValueError("Drawing object names are used by incompatible objects"); + if (update) { + box->Length.setValue(sizes[0]); + box->Width.setValue(sizes[1]); + box->Height.setValue(sizes[2]); + } + if (created) { + templ->Template.setValue("/freecad-mod/TechDraw/Templates/ISO/A4_Landscape_blank.svg"); + page->Template.setValue(templ); + page->addView(view); + view->Source.setValues({box}); + view->Direction.setValue(Base::Vector3d(0, -1, 0)); + view->XDirection.setValue(Base::Vector3d(1, 0, 0)); + } + stage = "recompute"; + bool failed = false; + doc->recompute({}, false, &failed); + if (failed || box->isError() || view->isError() || view->getEdgeGeometry().empty()) + throw Base::RuntimeError("Drawing recompute did not produce usable native geometry"); + stage = "render"; + auto decref = [](PyObject* value) { Py_XDECREF(value); }; + using PythonRef = std::unique_ptr; + PythonRef viewObject(view->getPyObject(), decref); + auto* module = PyDict_GetItemString(PyImport_GetModuleDict(), "TechDraw"); + PythonRef rendered(viewObject && module + ? PyObject_CallMethod(module, "viewPartAsSvg", "O", viewObject.get()) : nullptr, decref); + const char* svg = rendered && PyUnicode_Check(rendered.get()) ? PyUnicode_AsUTF8(rendered.get()) : nullptr; + if (!svg || !*svg) throw Base::RuntimeError(pythonError("Native SVG rendering failed")); + GProp_GProps properties; + BRepGProp::VolumeProperties(box->Shape.getValue(), properties); + const double volume = properties.Mass(); + if (!std::isfinite(volume) || volume <= 0) throw Base::RuntimeError("Native shape volume is invalid"); + const auto bounds = view->getBoundingBox(); + result.insert(QStringLiteral("ok"), true); + result.insert(QStringLiteral("schema"), args.value(QStringLiteral("schema"))); + result.insert(QStringLiteral("module"), QStringLiteral("TechDraw")); + result.insert(QStringLiteral("action"), QStringLiteral("create/recompute")); + result.insert(QStringLiteral("source"), QStringLiteral("freecad-source")); + result.insert(QStringLiteral("nativeSource"), QStringLiteral("TechDraw::DrawViewPart")); + result.insert(QStringLiteral("renderer"), QStringLiteral("TechDraw.viewPartAsSvg")); + result.insert(QStringLiteral("box"), QJsonObject{{"length", box->Length.getValue()}, {"width", box->Width.getValue()}, {"height", box->Height.getValue()}}); + result.insert(QStringLiteral("volume"), volume); + result.insert(QStringLiteral("svg"), QString::fromUtf8(svg)); + result.insert(QStringLiteral("bounds"), QJsonArray{bounds.MinX, bounds.MinY, bounds.MaxX, bounds.MaxY}); + result.insert(QStringLiteral("edgeCount"), static_cast(view->getEdgeGeometry().size())); + result.insert(QStringLiteral("document"), QJsonObject{{"name", QString::fromUtf8(doc->getName())}, {"objectCount", doc->countObjects()}, {"keptOpen", true}}); + result.insert(QStringLiteral("page"), QString::fromUtf8(page->getNameInDocument())); + result.insert(QStringLiteral("pageSize"), QJsonArray{page->getPageWidth(), page->getPageHeight()}); + result.insert(QStringLiteral("view"), QString::fromUtf8(view->getNameInDocument())); + doc->commitTransaction(); + return result; + } catch (const Base::Exception& error) { + result = QJsonObject{{"ok", false}, {"code", "drawing_failed"}, {"error", QString::fromUtf8(error.what())}}; + } catch (const std::exception& error) { + result = QJsonObject{{"ok", false}, {"code", "drawing_failed"}, {"error", QString::fromUtf8(error.what())}}; + } catch (const Standard_Failure& error) { + result = QJsonObject{{"ok", false}, {"code", "drawing_failed"}, {"error", QString::fromUtf8(error.GetMessageString())}}; + } catch (...) { + result = QJsonObject{{"ok", false}, {"code", "drawing_failed"}, {"error", QString::fromStdString( + Py_IsInitialized() ? pythonError("Native drawing exception") : "Native drawing initialization exception")}}; + } + result.insert(QStringLiteral("stage"), QString::fromLatin1(stage)); + errorMessage = QJsonDocument(result).toJson(QJsonDocument::Compact).toStdString(); + if (Py_IsInitialized()) PyErr_Clear(); + try { + if (created && doc) App::GetApplication().closeDocument(doc); + else if (transaction && doc) doc->abortTransaction(); + } catch (...) { + result.insert(QStringLiteral("rollbackFailed"), true); + } + return result; +} + bool executeDrawViewPartMethod(const QString& name, QJsonObject& value, std::string& failure) { App::Document* document = nullptr; @@ -541,7 +674,375 @@ QJsonObject probePythonFamilyRecord(const QJsonObject& input, const QString& tie result.insert(QStringLiteral("native"), true); result.insert(QStringLiteral("sourceEntry"), sourcePath); result.insert(QStringLiteral("sourceRevision"), QString::fromUtf8(techdrawPythonSourceRevision)); result.insert(QStringLiteral("resourcesReleased"), App::GetApplication().getDocuments().empty()); - // FC_WEB_AUDITED_DESKTOP_FAMILY("TechDraw") + // FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ClipGroup") + // FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ClipGroupAdd") + // FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ClipGroupRemove") + if (apiId.startsWith(QStringLiteral("TechDraw:desktop:"))) { + const bool createClip = name == QStringLiteral("TechDraw_ClipGroup"); + const bool addClip = name == QStringLiteral("TechDraw_ClipGroupAdd"); + const bool removeClip = name == QStringLiteral("TechDraw_ClipGroupRemove"); + if (createClip || addClip || removeClip) { + QJsonObject args = input.value(QStringLiteral("args")).toObject(); + if (args.value(QStringLiteral("args")).isObject()) args = args.value(QStringLiteral("args")).toObject(); + result.insert(QStringLiteral("parameterSchema"), QStringLiteral("{document:string,page:string,clip?:string,view?:string}")); + App::Document* document = nullptr; + bool transaction = false; + try { + if (apiId != QStringLiteral("TechDraw:desktop:") + name) + throw Base::RuntimeError("Desktop command ID/name mismatch"); + const QString documentName = args.value(QStringLiteral("document")).toString(); + const QString pageName = args.value(QStringLiteral("page")).toString(); + if (documentName.isEmpty() || pageName.isEmpty()) + throw Base::RuntimeError("document and page handles are required"); + document = App::GetApplication().getDocument(documentName.toUtf8().constData()); + auto* page = document ? dynamic_cast(document->getObject(pageName.toUtf8().constData())) : nullptr; + if (!page) throw Base::RuntimeError("Selected document/page does not exist or is not a DrawPage"); + TechDraw::DrawViewClip* clip = nullptr; + TechDraw::DrawView* view = nullptr; + if (!createClip) { + const QString viewName = args.value(QStringLiteral("view")).toString(); + if (viewName.isEmpty()) throw Base::RuntimeError("A view handle is required"); + view = dynamic_cast(document->getObject(viewName.toUtf8().constData())); + if (!view || view->findParentPage() != page) + throw Base::RuntimeError("Selected view must belong to the selected page"); + if (addClip) { + const QString clipName = args.value(QStringLiteral("clip")).toString(); + if (clipName.isEmpty()) throw Base::RuntimeError("A clip handle is required"); + clip = dynamic_cast(document->getObject(clipName.toUtf8().constData())); + if (!clip || clip->findParentPage() != page || dynamic_cast(view)) + throw Base::RuntimeError("Select one clip group and one non-clip view from the same page"); + if (clip->isViewInClip(view)) throw Base::RuntimeError("View already belongs to the clip"); + } else { + for (auto* candidate : page->getViews()) { + auto* candidateClip = dynamic_cast(candidate); + if (candidateClip && candidateClip->isViewInClip(view)) { clip = candidateClip; break; } + } + if (!clip) throw Base::RuntimeError("View does not belong to a clip"); + } + } + document->openTransaction(name.toUtf8().constData()); + transaction = true; + if (createClip) { + clip = dynamic_cast(document->addObject("TechDraw::DrawViewClip", "Clip")); + if (!clip) throw Base::RuntimeError("DrawViewClip creation failed"); + page->addView(clip); + } else if (addClip) { + clip->addView(view); + } else { + clip->removeView(view); + } + document->recompute(); + if (clip->findParentPage() != page || (!createClip && clip->isViewInClip(view) != addClip)) + throw Base::RuntimeError("Clip membership update failed"); + QJsonObject typed; + typed.insert(QStringLiteral("document"), documentName); + typed.insert(QStringLiteral("page"), QString::fromUtf8(page->getNameInDocument())); + typed.insert(QStringLiteral("clip"), QString::fromUtf8(clip->getNameInDocument())); + QJsonArray members; + for (auto* member : clip->getViews()) members.push_back(QString::fromUtf8(member->getNameInDocument())); + typed.insert(QStringLiteral("views"), members); + if (view) typed.insert(QStringLiteral("view"), QString::fromUtf8(view->getNameInDocument())); + document->commitTransaction(); + transaction = false; + result.insert(QStringLiteral("result"), typed); + if (name == QStringLiteral("TechDraw_ClipGroup")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_ClipGroupAdd")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_ClipGroupRemove")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command")); + } catch (const Base::Exception& error) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("invalid_selection_or_operation")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } catch (const std::exception& error) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } + result.insert(QStringLiteral("resourcesReleased"), App::GetApplication().getDocuments().empty()); + return result; + } + if (dispatchTechDrawDesktopCommand(name, input, result)) return result; + QString dependency; + if (name == QStringLiteral("TechDraw_ProjectionGroup")) dependency = QStringLiteral("Official projection-group task selection, projection directions and DrawProjGroup creation contract"); + else if (name == QStringLiteral("TechDraw_SectionGroup")) dependency = QStringLiteral("Official section-group command mode selection and section task execution contract"); + else if (name == QStringLiteral("TechDraw_ActiveView")) dependency = QStringLiteral("Active viewport capture and TaskDlgActiveView parameters"); + else if (name == QStringLiteral("TechDraw_ArchView")) dependency = QStringLiteral("BIM section-plane validation and DrawViewArch source/render contract"); + else if (name == QStringLiteral("TechDraw_Balloon")) dependency = QStringLiteral("Page placement/selected geometry and QGSPage balloon creation semantics"); + else if (name == QStringLiteral("TechDraw_BrokenView")) dependency = QStringLiteral("Base/source selection, break definitions and broken-view task execution"); + else if (name == QStringLiteral("TechDraw_ComplexSection")) dependency = QStringLiteral("execComplexSection selection and section task parameters"); + else if (name == QStringLiteral("TechDraw_DetailView")) dependency = QStringLiteral("TaskDlgDetail base view, detail boundary and placement contract"); + else if (name == QStringLiteral("TechDraw_DraftView")) dependency = QStringLiteral("DrawViewDraft source renderer and viewport direction/rotation"); + else if (name == QStringLiteral("TechDraw_ExportPageDXF")) dependency = QStringLiteral("Official page DXF exporter and browser file delivery"); + else if (name == QStringLiteral("TechDraw_ExportPageSVG")) dependency = QStringLiteral("MDIViewPage SVG scene export and browser file delivery"); + else if (name == QStringLiteral("TechDraw_PageDefault")) dependency = QStringLiteral("Official default template selection and page/template creation"); + else if (name == QStringLiteral("TechDraw_PageTemplate")) dependency = QStringLiteral("User template asset ingestion and page/template creation"); + else if (name == QStringLiteral("TechDraw_PrintAll")) dependency = QStringLiteral("Rendered document pages and browser equivalent of MDIViewPage::printAllPages"); + else if (name == QStringLiteral("TechDraw_ProjectShape")) dependency = QStringLiteral("Official projection task parameters and selected shape projection handler"); + if (!dependency.isEmpty()) { + result.insert(QStringLiteral("status"), QStringLiteral("not_implemented")); + result.insert(QStringLiteral("code"), QStringLiteral("implementation_required")); + result.insert(QStringLiteral("message"), dependency); + return result; + } + } + // FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionLockUnlockView") + // FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionExtendLine") + // FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionShortenLine") + // FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionExtendShortenLineGroup") + if (apiId.startsWith(QStringLiteral("TechDraw:desktop:")) + && (name == QStringLiteral("TechDraw_ExtensionLockUnlockView") + || name == QStringLiteral("TechDraw_ExtensionExtendLine") + || name == QStringLiteral("TechDraw_ExtensionShortenLine") + || name == QStringLiteral("TechDraw_ExtensionExtendShortenLineGroup"))) { + QJsonObject args = input.value(QStringLiteral("args")).toObject(); + if (args.value(QStringLiteral("args")).isObject()) args = args.value(QStringLiteral("args")).toObject(); + const bool lock = name == QStringLiteral("TechDraw_ExtensionLockUnlockView"); + result.insert(QStringLiteral("parameterSchema"), lock + ? QStringLiteral("{document:string,selection:[viewName:string]}") + : QStringLiteral("{document:string,view:string,selection:[edgeName:string],lineStretch:number,iMsg?:0|1}")); + App::Document* document = nullptr; + bool transaction = false; + try { + if (apiId != QStringLiteral("TechDraw:desktop:") + name) + throw Base::RuntimeError("Desktop command ID/name mismatch"); + const QString documentName = args.value(QStringLiteral("document")).toString(); + if (documentName.isEmpty()) throw Base::RuntimeError("document handle is required"); + document = App::GetApplication().getDocument(documentName.toUtf8().constData()); + if (!document) throw Base::RuntimeError("Document does not exist"); + const QJsonArray selection = args.value(QStringLiteral("selection")).toArray(); + if (selection.isEmpty()) throw Base::RuntimeError("Selection is required"); + QJsonObject typed; + typed.insert(QStringLiteral("document"), documentName); + if (lock) { + std::vector views; + QSet seen; + for (const auto& selected : selection) { + const QString handle = selected.toString(); + auto* view = dynamic_cast(document->getObject(handle.toUtf8().constData())); + if (!view) throw Base::RuntimeError("Selection must contain existing DrawViewPart handles"); + if (!seen.contains(handle)) { views.push_back(view); seen.insert(handle); } + } + document->openTransaction("Lock/Unlock View"); + transaction = true; + QJsonArray changed; + for (auto* view : views) { + view->LockPosition.setValue(!view->LockPosition.getValue()); + QJsonObject state; + state.insert(QStringLiteral("view"), QString::fromUtf8(view->getNameInDocument())); + state.insert(QStringLiteral("lockPosition"), view->LockPosition.getValue()); + changed.push_back(state); + } + typed.insert(QStringLiteral("views"), changed); + } else { + const QString viewName = args.value(QStringLiteral("view")).toString(); + auto* view = dynamic_cast(document->getObject(viewName.toUtf8().constData())); + if (!view) throw Base::RuntimeError("view must identify an existing DrawViewPart"); + bool extend = name != QStringLiteral("TechDraw_ExtensionShortenLine"); + if (name == QStringLiteral("TechDraw_ExtensionExtendShortenLineGroup")) { + const auto mode = args.value(QStringLiteral("iMsg")); + if (!mode.isDouble() || (mode.toDouble() != 0.0 && mode.toDouble() != 1.0)) + throw Base::RuntimeError("iMsg must select extend (0) or shorten (1)"); + extend = mode.toInt() == 0; + } + const auto stretchValue = args.value(QStringLiteral("lineStretch")); + const double stretch = stretchValue.toDouble(); + if (!stretchValue.isDouble() || !std::isfinite(stretch) || stretch <= 0.0) + throw Base::RuntimeError("lineStretch must be a positive finite length"); + const QString sub = selection.first().toString(); + bool valid = false; + const int index = sub.mid(4).toInt(&valid); + if (!sub.startsWith(QStringLiteral("Edge")) || !valid || index < 1) + throw Base::RuntimeError("Select a cosmetic straight Edge"); + auto geometry = view->getGeomByIndex(index - 1); + auto line = std::dynamic_pointer_cast(geometry); + if (!line || line->points.size() != 2 || !geometry->getCosmetic()) + throw Base::RuntimeError("Selected edge must be a cosmetic straight line or centerline"); + const auto p0 = TechDraw::CosmeticVertex::makeCanonicalPointInverted(view, geometry->getStartPoint()); + const auto p1 = TechDraw::CosmeticVertex::makeCanonicalPointInverted(view, geometry->getEndPoint()); + const double length = (p1 - p0).Length(); + if (!std::isfinite(length) || length <= 0.0 || (!extend && 2.0 * stretch >= length)) + throw Base::RuntimeError("Stretch would create a zero length or reversed line"); + std::string tag = geometry->getCosmeticTag(); + auto* edge = geometry->source() == TechDraw::SourceType::COSMETICEDGE ? view->getCosmeticEdge(tag) : nullptr; + auto* center = geometry->source() == TechDraw::SourceType::CENTERLINE ? view->getCenterLine(tag) : nullptr; + if (!edge && !center) throw Base::RuntimeError("Selected cosmetic object does not exist"); + document->openTransaction("Extend/shorten line"); + transaction = true; + if (center) { + center->m_extendBy += extend ? stretch : -stretch; + view->refreshCLGeoms(); + typed.insert(QStringLiteral("extendBy"), center->m_extendBy); + } else { + const auto format = edge->format(); + const auto delta = (p1 - p0) * ((extend ? stretch : -stretch) / length); + view->removeCosmeticEdge(std::vector{tag}); + tag = view->addCosmeticEdge(p0 - delta, p1 + delta); + auto* replacement = view->getCosmeticEdge(tag); + if (!replacement) throw Base::RuntimeError("Cosmetic edge replacement failed"); + replacement->setFormat(format); + view->refreshCEGeoms(); + } + view->requestPaint(); + typed.insert(QStringLiteral("view"), viewName); + typed.insert(QStringLiteral("tag"), QString::fromStdString(tag)); + typed.insert(QStringLiteral("cosmeticEdgeCount"), static_cast(view->CosmeticEdges.getValues().size())); + } + document->recompute(); + document->commitTransaction(); + transaction = false; + result.insert(QStringLiteral("result"), typed); + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command")); + } catch (const Base::Exception& error) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("invalid_selection_or_operation")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } catch (const std::exception& error) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } + result.insert(QStringLiteral("resourcesReleased"), App::GetApplication().getDocuments().empty()); + return result; + } + // FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionLineParallel") + // FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionLinePerpendicular") + // FC_WEB_IMPLEMENTED_DESKTOP_COMMAND("TechDraw_ExtensionLinePPGroup") + if (apiId.startsWith(QStringLiteral("TechDraw:desktop:"))) { + const bool lineCommand = name == QStringLiteral("TechDraw_ExtensionLineParallel") + || name == QStringLiteral("TechDraw_ExtensionLinePerpendicular") + || name == QStringLiteral("TechDraw_ExtensionLinePPGroup"); + static const QSet pendingCommands = { + QStringLiteral("TechDraw_CosmeticCircle"), + QStringLiteral("TechDraw_ExtensionArcLengthAnnotation"), + QStringLiteral("TechDraw_ExtensionAreaAnnotation"), + QStringLiteral("TechDraw_ExtensionChangeLineAttributes"), + QStringLiteral("TechDraw_ExtensionCircleCenterLines"), + QStringLiteral("TechDraw_ExtensionCircleCenterLinesGroup"), + QStringLiteral("TechDraw_ExtensionDrawCirclesGroup"), + QStringLiteral("TechDraw_ExtensionDrawCosmArc"), + QStringLiteral("TechDraw_ExtensionDrawCosmCircle"), + QStringLiteral("TechDraw_ExtensionDrawCosmCircle3Points"), + QStringLiteral("TechDraw_ExtensionSelectLineAttributes"), + QStringLiteral("TechDraw_ExtensionHoleCircle") + }; + if (pendingCommands.contains(name)) { + result.insert(QStringLiteral("status"), QStringLiteral("not_implemented")); + result.insert(QStringLiteral("code"), QStringLiteral("implementation_required")); + QString dependency; + if (name.contains(QStringLiteral("Annotation"))) + dependency = QStringLiteral("Selected projected geometry measurement and DrawViewBalloon creation/placement"); + else if (name == QStringLiteral("TechDraw_ExtensionSelectLineAttributes")) + dependency = QStringLiteral("React line attribute task dialog and shared active line style/width/color/stretch state consumed by native edit commands"); + else if (name.contains(QStringLiteral("Attributes"))) + dependency = QStringLiteral("Selected cosmetic edge/centerline LineFormat mutation and refresh"); + else if (name.contains(QStringLiteral("Extend"))) + dependency = QStringLiteral("Selected line extension/shortening and cosmetic edge replacement"); + else if (name.contains(QStringLiteral("CenterLines")) || name.contains(QStringLiteral("HoleCircle"))) + dependency = QStringLiteral("Selected circle geometry and official centerline/bolt-circle construction"); + else + dependency = QStringLiteral("Cosmetic circle/arc task parameters, selected-point construction and edit transaction"); + result.insert(QStringLiteral("message"), dependency); + return result; + } + if (lineCommand) { + QJsonObject args = input.value(QStringLiteral("args")).toObject(); + if (args.value(QStringLiteral("args")).isObject()) args = args.value(QStringLiteral("args")).toObject(); + result.insert(QStringLiteral("parameterSchema"), QStringLiteral("{document:string,view:string,selection:[string,string],iMsg?:0|1}")); + App::Document* document = nullptr; + bool transaction = false; + try { + if (apiId != QStringLiteral("TechDraw:desktop:") + name) + throw Base::RuntimeError("Desktop command ID/name mismatch"); + const QString documentName = args.value(QStringLiteral("document")).toString(); + const QString viewName = args.value(QStringLiteral("view")).toString(); + if (documentName.isEmpty() || viewName.isEmpty()) + throw Base::RuntimeError("document and view handles are required"); + document = App::GetApplication().getDocument(documentName.toUtf8().constData()); + auto* view = document ? dynamic_cast(document->getObject(viewName.toUtf8().constData())) : nullptr; + if (!view) throw Base::RuntimeError("The selected document/view does not exist or is not a DrawViewPart"); + bool parallel = name != QStringLiteral("TechDraw_ExtensionLinePerpendicular"); + if (name == QStringLiteral("TechDraw_ExtensionLinePPGroup")) { + const auto mode = args.value(QStringLiteral("iMsg")); + if (!mode.isDouble() || (mode.toDouble() != 0.0 && mode.toDouble() != 1.0)) + throw Base::RuntimeError("iMsg must select parallel (0) or perpendicular (1)"); + parallel = mode.toInt() == 0; + } + const QJsonArray selection = args.value(QStringLiteral("selection")).toArray(); + if (selection.size() != 2) throw Base::RuntimeError("Select one projected Edge and one Vertex"); + int edgeIndex = -1, vertexIndex = -1; + for (const auto& selected : selection) { + const QString sub = selected.toString(); + bool valid = false; + if (sub.startsWith(QStringLiteral("Edge"))) { + const int index = sub.mid(4).toInt(&valid); + if (valid && index > 0) edgeIndex = index - 1; + } else if (sub.startsWith(QStringLiteral("Vertex"))) { + const int index = sub.mid(6).toInt(&valid); + if (valid && index > 0) vertexIndex = index - 1; + } + } + if (edgeIndex < 0 || vertexIndex < 0) throw Base::RuntimeError("Select one projected Edge and one Vertex"); + auto geometry = view->getGeomByIndex(edgeIndex); + auto line = std::dynamic_pointer_cast(geometry); + auto vertex = view->getProjVertexByIndex(vertexIndex); + if (!line || line->points.size() != 2 || !vertex) + throw Base::RuntimeError("Selection must contain a straight edge and an existing vertex"); + const auto start = TechDraw::CosmeticVertex::makeCanonicalPointInverted(view, line->points.at(0)); + const auto end = TechDraw::CosmeticVertex::makeCanonicalPointInverted(view, line->points.at(1)); + const auto point = vertex->point(); + const auto center = TechDraw::CosmeticVertex::makeCanonicalPointInverted(view, Base::Vector3d(point.x, point.y, 0.0)); + Base::Vector3d half = (end - start) / 2.0; + if (!std::isfinite(half.x) || !std::isfinite(half.y) || (half.x == 0.0 && half.y == 0.0)) + throw Base::RuntimeError("Selected edge has invalid or zero length"); + if (!parallel) { const double x = half.x; half.x = -half.y; half.y = x; } + document->openTransaction("Cosmetic Line Parallel/Perpendicular"); + transaction = true; + const std::string tag = view->addCosmeticEdge(TechDraw::CosmeticEdge::makeLineFromCanonicalPoints(center + half, center - half)); + if (tag.empty() || !view->getCosmeticEdge(tag)) throw Base::RuntimeError("Cosmetic line creation failed"); + view->refreshCEGeoms(); + view->requestPaint(); + document->recompute(); + document->commitTransaction(); + transaction = false; + QJsonObject typed; + typed.insert(QStringLiteral("document"), documentName); + typed.insert(QStringLiteral("view"), viewName); + typed.insert(QStringLiteral("tag"), QString::fromStdString(tag)); + typed.insert(QStringLiteral("cosmeticEdgeCount"), static_cast(view->CosmeticEdges.getValues().size())); + result.insert(QStringLiteral("result"), typed); + if (name == QStringLiteral("TechDraw_ExtensionLineParallel")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_ExtensionLinePerpendicular")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } else if (name == QStringLiteral("TechDraw_ExtensionLinePPGroup")) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + } + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command")); + result.insert(QStringLiteral("returnSchema"), QStringLiteral("{document:string,view:string,tag:string,cosmeticEdgeCount:int}")); + } catch (const Base::Exception& error) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("invalid_selection_or_operation")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } catch (const std::exception& error) { + if (transaction) document->abortTransaction(); + result.insert(QStringLiteral("status"), QStringLiteral("error")); + result.insert(QStringLiteral("code"), QStringLiteral("native_desktop_command_failed")); + result.insert(QStringLiteral("message"), QString::fromUtf8(error.what())); + } + result.insert(QStringLiteral("resourcesReleased"), App::GetApplication().getDocuments().empty()); + return result; + } + } if (apiId.startsWith(QStringLiteral("TechDraw:desktop:")) && !name.isEmpty()) { static const QSet desktopCommands = {QStringLiteral("TechDraw_ExtensionCascadeDimensionGroup"), QStringLiteral("TechDraw_ExtensionCascadeHorizDimension"), QStringLiteral("TechDraw_ExtensionCascadeObliqueDimension"), QStringLiteral("TechDraw_ExtensionCascadeVertDimension"), QStringLiteral("TechDraw_ExtensionChamferDimensionGroup"), QStringLiteral("TechDraw_ExtensionCreateChainDimensionGroup"), QStringLiteral("TechDraw_ExtensionCreateCoordDimensionGroup"), QStringLiteral("TechDraw_ExtensionCreateHorizChainDimension"), QStringLiteral("TechDraw_ExtensionCreateHorizChamferDimension"), QStringLiteral("TechDraw_ExtensionCreateHorizCoordDimension"), QStringLiteral("TechDraw_ExtensionCreateLengthArc"), QStringLiteral("TechDraw_ExtensionCreateObliqueChainDimension"), QStringLiteral("TechDraw_ExtensionCreateObliqueCoordDimension"), QStringLiteral("TechDraw_ExtensionCreateVertChainDimension"), QStringLiteral("TechDraw_ExtensionCreateVertChamferDimension"), QStringLiteral("TechDraw_ExtensionCreateVertCoordDimension")}; if (!desktopCommands.contains(name)) { result.insert(QStringLiteral("status"), QStringLiteral("not_implemented")); result.insert(QStringLiteral("code"), QStringLiteral("unregistered_desktop_command")); result.insert(QStringLiteral("message"), QStringLiteral("TechDraw desktop command is not registered")); return result; } @@ -1015,6 +1516,21 @@ EMSCRIPTEN_KEEPALIVE const char* fc_web_dispatch_json(const char* request) if (!document.isObject()) { pythonFamilyDispatchResponse = R"({"ok":false,"code":"invalid_request","error":"TechDraw native dispatch requires a JSON object"})"; return pythonFamilyDispatchResponse.c_str(); } const QJsonObject object = document.object(); const QString command = object.value(QStringLiteral("cmd")).toString(); + if (command == QStringLiteral("techdraw.createRecompute")) { + if (!object.value(QStringLiteral("args")).isObject()) { + pythonFamilyDispatchResponse = R"({"ok":false,"code":"invalid_request","error":"techdraw.createRecompute requires an args object"})"; + return pythonFamilyDispatchResponse.c_str(); + } + QJsonObject result = createDrawing(object.value(QStringLiteral("args")).toObject()); + result.insert(QStringLiteral("cmd"), command); + if (result.value(QStringLiteral("ok")).toBool()) { + result.insert(QStringLiteral("status"), QStringLiteral("exposed")); + result.insert(QStringLiteral("native"), true); + } + pythonFamilyDispatchResponse = QJsonDocument(result) + .toJson(QJsonDocument::Compact).toStdString(); + return pythonFamilyDispatchResponse.c_str(); + } if (command == QStringLiteral("techdraw.pythonMethod")) { const QJsonObject args = object.value(QStringLiteral("args")).toObject(); const QString schema = args.value(QStringLiteral("schema")).toString();