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

This commit is contained in:
2026-09-08 15:05:38 +08:00
parent 6f99794003
commit 77925a996d
23 changed files with 6099 additions and 108 deletions

View 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
}
}
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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():

View File

@@ -88,7 +88,12 @@ target_include_directories(
${CMAKE_CURRENT_BINARY_DIR}
${CMAKE_CURRENT_SOURCE_DIR}
)
target_link_libraries(Import ${Import_LIBS})
target_link_libraries(Import ${Import_LIBS})
if(EMSCRIPTEN)
# Keep native parser failures inside the Python error/transaction boundary.
target_compile_options(Import PRIVATE "-fexceptions")
endif()
if (MSVC)
target_compile_options(Import PRIVATE /wd4251 /wd4275)

View File

@@ -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

View File

@@ -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

View File

@@ -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:")))) {

View File

@@ -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) {

View File

@@ -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")

View File

@@ -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();

View File

@@ -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();

View 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;
}

View File

@@ -0,0 +1,7 @@
#pragma once
#include <QJsonObject>
#include <QString>
extern "C" QJsonObject fcWebPartDesignDesktopCommand(const QString& command,
const QJsonObject& args);

View File

@@ -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(); }

View File

@@ -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();
}

View File

@@ -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"));

View 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;
}

View File

@@ -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);

View File

@@ -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"),

File diff suppressed because it is too large Load Diff

View File

@@ -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);

View File

@@ -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();