Extend FreeCAD WebAssembly runtime support
Some checks failed
FreeCAD master CI / Prepare (push) Has been cancelled
FreeCAD master CI / Pixi (push) Has been cancelled
FreeCAD master CI / Ubuntu (push) Has been cancelled
FreeCAD master CI / Windows (push) Has been cancelled
FreeCAD master CI / Lint (push) Has been cancelled
FreeCAD master CI / WrapUp (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
Some checks failed
FreeCAD master CI / Prepare (push) Has been cancelled
FreeCAD master CI / Pixi (push) Has been cancelled
FreeCAD master CI / Ubuntu (push) Has been cancelled
FreeCAD master CI / Windows (push) Has been cancelled
FreeCAD master CI / Lint (push) Has been cancelled
FreeCAD master CI / WrapUp (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
This commit is contained in:
@@ -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"));
|
||||
|
||||
Reference in New Issue
Block a user