Extend FreeCAD WebAssembly runtime support
Some checks failed
FreeCAD master CI / Prepare (push) Has been cancelled
FreeCAD master CI / Pixi (push) Has been cancelled
FreeCAD master CI / Ubuntu (push) Has been cancelled
FreeCAD master CI / Windows (push) Has been cancelled
FreeCAD master CI / Lint (push) Has been cancelled
FreeCAD master CI / WrapUp (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Some checks failed
FreeCAD master CI / Prepare (push) Has been cancelled
FreeCAD master CI / Pixi (push) Has been cancelled
FreeCAD master CI / Ubuntu (push) Has been cancelled
FreeCAD master CI / Windows (push) Has been cancelled
FreeCAD master CI / Lint (push) Has been cancelled
FreeCAD master CI / WrapUp (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
This commit is contained in:
23
docs/business-acceptance/Part.json
Normal file
23
docs/business-acceptance/Part.json
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -90,6 +90,11 @@ target_include_directories(
|
||||
)
|
||||
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)
|
||||
endif()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Assembly::AssemblyObject*>(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:")))) {
|
||||
|
||||
@@ -27,6 +27,8 @@
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QCryptographicHash>
|
||||
#include <QRegularExpression>
|
||||
#include <QString>
|
||||
|
||||
#include <cstddef>
|
||||
@@ -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<std::string, double> 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<Path::Feature*>(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<Path::Feature*>(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<int>(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<int>(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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
#include <Mod/Part/App/PrimitiveFeature.h>
|
||||
#include <Mod/Part/App/PropertyTopoShape.h>
|
||||
#include <Mod/Part/App/TopoShape.h>
|
||||
#include "PartDesktopCommands.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
@@ -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();
|
||||
|
||||
230
src/WebAssembly/FreeCADCoreProbe/PartDesignDesktopCommands.cpp
Normal file
230
src/WebAssembly/FreeCADCoreProbe/PartDesignDesktopCommands.cpp
Normal file
@@ -0,0 +1,230 @@
|
||||
#include <Python.h>
|
||||
#include "PartDesignDesktopCommands.h"
|
||||
#include <QJsonDocument>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
#pragma once
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
|
||||
extern "C" QJsonObject fcWebPartDesignDesktopCommand(const QString& command,
|
||||
const QJsonObject& args);
|
||||
@@ -446,9 +446,21 @@ DocumentGraphMethodFixture runDocumentGraphMethodFixture(const std::string& meth
|
||||
auto* first = document ? dynamic_cast<PartDesign::Feature*>(document->addObject("PartDesign::Feature", "First")) : nullptr;
|
||||
auto* second = document ? dynamic_cast<PartDesign::Feature*>(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<int>(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(); }
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
|
||||
#include <Python.h>
|
||||
|
||||
#include "PartDesktopCommands.h"
|
||||
|
||||
#include <QJsonDocument>
|
||||
|
||||
#include <BRepBuilderAPI_Copy.hxx>
|
||||
#include <BRepExtrema_DistShapeShape.hxx>
|
||||
#include <Precision.hxx>
|
||||
#include <ShapeFix_ShapeTolerance.hxx>
|
||||
|
||||
#include <BOPAlgo_Builder.hxx>
|
||||
#include <TopoDS_Iterator.hxx>
|
||||
#include <BRepAlgoAPI_Common.hxx>
|
||||
#include <BRepAlgoAPI_Cut.hxx>
|
||||
#include <BRepAlgoAPI_Fuse.hxx>
|
||||
#include <BRepAlgoAPI_Section.hxx>
|
||||
#include <BRepAlgoAPI_Splitter.hxx>
|
||||
#include <TopTools_ListOfShape.hxx>
|
||||
#include <TopTools_ListIteratorOfListOfShape.hxx>
|
||||
#include <BRepBuilderAPI_MakeFace.hxx>
|
||||
#include <BRepBuilderAPI_MakePolygon.hxx>
|
||||
#include <BRepBuilderAPI_MakeSolid.hxx>
|
||||
@@ -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<TopoDS_Shape> 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<double>::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<TopoDS_Shape> children;
|
||||
std::vector<double> values;
|
||||
double maximum = -std::numeric_limits<double>::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<double>::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<int>(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<double>::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<TopoDS_Shape> 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<double>::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<TopoDS_Shape> 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<TopoDS_Shape> 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<TopoDS_Shape> 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<std::size_t> owners;
|
||||
};
|
||||
std::vector<Piece> 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<TopoDS_Shape> 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<TopoDS_Shape> 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<TopoDS_Shape> 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<double>::quiet_NaN() : 0.0);
|
||||
if (!std::isfinite(tolerance) || tolerance < 0.0) {
|
||||
error = QStringLiteral("XOR tolerance must be finite and nonnegative");
|
||||
return false;
|
||||
}
|
||||
std::vector<TopoDS_Shape> 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<TopoDS_Shape> 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<double>::quiet_NaN() : 0.0);
|
||||
if (!std::isfinite(tolerance) || tolerance < 0.0) {
|
||||
error = QStringLiteral("tolerance must be a finite nonnegative number");
|
||||
return false;
|
||||
}
|
||||
std::vector<TopoDS_Shape> 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"));
|
||||
|
||||
121
src/WebAssembly/FreeCADCoreProbe/SketcherDesktopCommands.cpp
Normal file
121
src/WebAssembly/FreeCADCoreProbe/SketcherDesktopCommands.cpp
Normal file
@@ -0,0 +1,121 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
#include <Python.h>
|
||||
#include "SketcherDesktopCommands.h"
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonParseError>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
#pragma once
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
#include <string>
|
||||
|
||||
bool executeSketcherDesktopCommand(const QString& command, const QJsonObject& args,
|
||||
QJsonObject& output, std::string& error);
|
||||
@@ -1,6 +1,7 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
|
||||
#include <App/Application.h>
|
||||
#include "SketcherDesktopCommands.h"
|
||||
#include <App/Document.h>
|
||||
#include <Base/Exception.h>
|
||||
#include <Base/FileInfo.h>
|
||||
@@ -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"),
|
||||
|
||||
3539
src/WebAssembly/FreeCADCoreProbe/TechDrawDesktopCommands.cpp
Normal file
3539
src/WebAssembly/FreeCADCoreProbe/TechDrawDesktopCommands.cpp
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,7 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
#pragma once
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
|
||||
bool dispatchTechDrawDesktopCommand(const QString& name, const QJsonObject& input,
|
||||
QJsonObject& result);
|
||||
@@ -1,15 +1,19 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
#include <App/Application.h>
|
||||
#include "TechDrawDesktopCommands.h"
|
||||
#include <App/Document.h>
|
||||
#include <Base/Exception.h>
|
||||
#include <Base/FileInfo.h>
|
||||
#include <Base/Type.h>
|
||||
#include <Mod/Part/App/FeaturePartBox.h>
|
||||
#include <Mod/TechDraw/App/Cosmetic.h>
|
||||
#include <Mod/TechDraw/App/CenterLine.h>
|
||||
#include <Mod/TechDraw/App/DrawPage.h>
|
||||
#include <Mod/TechDraw/App/DrawSVGTemplate.h>
|
||||
#include <Mod/TechDraw/App/DrawProjGroup.h>
|
||||
#include <Mod/TechDraw/App/DrawProjGroupItem.h>
|
||||
#include <Mod/TechDraw/App/DrawViewDimension.h>
|
||||
#include <Mod/TechDraw/App/DrawViewClip.h>
|
||||
#include <Mod/TechDraw/App/DrawViewPart.h>
|
||||
#include <Mod/TechDraw/App/GeometryObject.h>
|
||||
#include <Mod/TechDraw/App/LineFormat.h>
|
||||
@@ -19,10 +23,14 @@
|
||||
#include <QJsonObject>
|
||||
#include <QJsonParseError>
|
||||
#include <QSet>
|
||||
#include <QRegularExpression>
|
||||
#include <QString>
|
||||
#include <BRepGProp.hxx>
|
||||
#include <GProp_GProps.hxx>
|
||||
#include <emscripten/emscripten.h>
|
||||
#include <cmath>
|
||||
#include <fstream>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
@@ -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<Part::Box*>(object("Part::Box", "Box"));
|
||||
auto* page = dynamic_cast<TechDraw::DrawPage*>(object("TechDraw::DrawPage", "Page"));
|
||||
auto* templ = dynamic_cast<TechDraw::DrawSVGTemplate*>(object("TechDraw::DrawSVGTemplate", "Template"));
|
||||
auto* view = dynamic_cast<TechDraw::DrawViewPart*>(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<PyObject, decltype(decref)>;
|
||||
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<int>(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<TechDraw::DrawPage*>(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<TechDraw::DrawView*>(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<TechDraw::DrawViewClip*>(document->getObject(clipName.toUtf8().constData()));
|
||||
if (!clip || clip->findParentPage() != page || dynamic_cast<TechDraw::DrawViewClip*>(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<TechDraw::DrawViewClip*>(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<TechDraw::DrawViewClip*>(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<TechDraw::DrawViewPart*> views;
|
||||
QSet<QString> seen;
|
||||
for (const auto& selected : selection) {
|
||||
const QString handle = selected.toString();
|
||||
auto* view = dynamic_cast<TechDraw::DrawViewPart*>(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<TechDraw::DrawViewPart*>(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<TechDraw::Generic>(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<std::string>{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<int>(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<QString> 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<TechDraw::DrawViewPart*>(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<TechDraw::Generic>(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<int>(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<QString> 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();
|
||||
|
||||
Reference in New Issue
Block a user