"""Typed parameters against native Sketcher, units, expressions and Qt controls.""" import json import io import math from pathlib import Path import sys import traceback import zipfile import FreeCAD as App import Part import Sketcher SOURCE = Path(__file__).resolve().parents[1] / "src" / "QInventor" if str(SOURCE) not in sys.path: sys.path.insert(0, str(SOURCE)) import qi_model as model def _near(actual, expected): assert math.isclose(float(actual), float(expected), rel_tol=1e-9, abs_tol=1e-7), (actual, expected) def _rows(obj): return {row["name"]: row for row in model.get_dimensions(obj)} def _state(sketch): # The native ZIP container timestamps vary during read-only calls. Every # persisted member payload still participates in the exact state check. raw = bytes(sketch.dumpContent()) if zipfile.is_zipfile(io.BytesIO(raw)): with zipfile.ZipFile(io.BytesIO(raw)) as archive: content = tuple((name, archive.read(name)) for name in sorted(archive.namelist())) else: content = raw return (content, list(sketch.ExpressionEngine), sketch.Document.UndoCount, sketch.Document.RedoCount) def _reject(action): try: action() except ValueError: return raise AssertionError("Invalid or read-only parameter was accepted") def _circle(prefix): doc = model.new_part(prefix) body = doc.addObject("PartDesign::Body", "Body") sketch = body.newObject("Sketcher::SketchObject", "Sketch") sketch.Label = "Координаты и размеры" sketch.addGeometry(Part.Circle(App.Vector(-10, 20, 0), App.Vector(0, 0, 1), 3), False) indices = {} for constraint, name in ((Sketcher.Constraint("DistanceX", 0, 3, -10), "ЦентрX"), (Sketcher.Constraint("DistanceY", 0, 3, 20), "ЦентрY"), (Sketcher.Constraint("Radius", 0, 3), "РадиусКруга")): index = sketch.addConstraint(constraint) sketch.renameConstraint(index, name) indices[name] = index doc.recompute() assert sketch.solve() == 0 return doc, body, sketch, indices def _line(sketch): index = sketch.addGeometry(Part.LineSegment(App.Vector(), App.Vector(10, 10, 0)), False) sketch.addConstraint(Sketcher.Constraint("Coincident", index, 1, -1, 1)) length = sketch.addConstraint(Sketcher.Constraint("Distance", index, math.sqrt(200))) sketch.renameConstraint(length, "ДлинаЛинии") angle = sketch.addConstraint(Sketcher.Constraint("Angle", index, math.pi / 4)) sketch.renameConstraint(angle, "Наклон") sketch.Document.recompute() assert sketch.solve() == 0 return index, angle def run(output_dir): output = Path(output_dir).absolute() output.mkdir(parents=True, exist_ok=True) initial_docs = set(App.listDocuments()) active_name = App.ActiveDocument.Name if App.ActiveDocument else None checks = [] def check(name, action): (output / "parameters-report.json").write_text( json.dumps({"checks": checks, "running_case": name}, ensure_ascii=False, indent=2), encoding="utf-8") App.Console.PrintLog("QInventor parameters test: " + name + "\n") try: checks.append({"name": name, "passed": True, "evidence": action()}) except Exception: checks.append({"name": name, "passed": False, "error": traceback.format_exc()}) (output / "parameters-report.json").write_text( json.dumps({"checks": checks}, ensure_ascii=False, indent=2), encoding="utf-8") def signed_coordinates_and_zero(): doc, _, sketch, ids = _circle("SignedCoordinates") rows = _rows(sketch) assert rows["ЦентрX"]["signed"] and rows["ЦентрY"]["signed"] assert not rows["РадиусКруга"]["signed"] App.Console.PrintLog("QInventor parameters test: apply signed X, zero Y and radius\n") model.set_dimensions(sketch, {"ЦентрX": -18, "ЦентрY": 0, "РадиусКруга": 4}) App.Console.PrintLog("QInventor parameters test: signed/zero apply returned\n") _near(sketch.Geometry[0].Center.x, -18) _near(sketch.Geometry[0].Center.y, 0) _near(sketch.Geometry[0].Radius, 4) assert sketch.Constraints[ids["ЦентрY"]].Type == "DistanceY" assert sketch.solve() == 0 doc.undo() doc.recompute() _near(sketch.Geometry[0].Center.x, -10) _near(sketch.Geometry[0].Center.y, 20) doc.redo() doc.recompute() _near(sketch.Geometry[0].Center.y, 0) return {"signed_x_mm": -18, "zero_y_mm": 0, "native_undo_redo": True} def angle_units(): _, _, sketch, _ = _circle("AngularParameters") line, angle = _line(sketch) row = _rows(sketch)["Наклон"] assert row["unit"] == "°" and row["signed"] _near(row["value"], 45) model.set_dimension(sketch, "Наклон", App.Units.Quantity("-30 deg")) _near(sketch.getDatum(angle).Value, -30) _near(sketch.Constraints[angle].Value, -math.pi / 6) direction = sketch.Geometry[line].EndPoint - sketch.Geometry[line].StartPoint _near(math.degrees(math.atan2(direction.y, direction.x)), -30) return {"editor_unit": "degrees", "kernel_constraint_unit": "radians", "signed_angle_degrees": -30} def expression_paths_and_noop(): doc, _, sketch, ids = _circle("ExpressionParameters") sizes = doc.addObject("App::VarSet", "SourceSizes") sizes.addProperty("App::PropertyDistance", "X") sizes.X = -12 sketch.setExpression("Constraints[%d]" % ids["ЦентрX"], "SourceSizes.X") sketch.setExpression("Constraints.РадиусКруга", "4 mm") doc.recompute() expressions = list(sketch.ExpressionEngine) rows = _rows(sketch) assert rows["ЦентрX"]["read_only"] and rows["РадиусКруга"]["read_only"] assert rows["ЦентрX"]["expression"] == "SourceSizes.X" undo = (doc.UndoCount, doc.RedoCount) assert model.set_dimensions(sketch, {name: row["value"] for name, row in rows.items()}) is False assert (doc.UndoCount, doc.RedoCount) == undo before = _state(sketch) _reject(lambda: model.set_dimensions(sketch, {"ЦентрX": -99, "ЦентрY": -15})) assert _state(sketch) == before model.set_dimension(sketch, "ЦентрY", -15) assert list(sketch.ExpressionEngine) == expressions sizes.X = -25 doc.recompute() _near(sketch.Geometry[0].Center.x, -25) return {"indexed_and_named_formula_paths": expressions, "no_op_preserves_undo": True, "formula_updates_still_associative": True} def reference_dimensions(): _, _, sketch, ids = _circle("ReferenceParameters") sketch.setDriving(ids["РадиусКруга"], False) sketch.Document.recompute() assert not sketch.getDriving(ids["РадиусКруга"]) rows = _rows(sketch) assert rows["РадиусКруга"]["read_only"] and rows["РадиусКруга"]["reason"] == "Справочный размер" before = _state(sketch) _reject(lambda: model.set_dimensions(sketch, {"ЦентрX": -18, "РадиусКруга": 5})) assert _state(sketch) == before model.set_dimension(sketch, "ЦентрX", -18) assert not sketch.getDriving(ids["РадиусКруга"]) _near(sketch.Geometry[0].Center.x, -18) return {"reference_radius_read_only": True, "rejected_edit_is_atomic": True} def delegated_body_parameters(): doc = model.new_part("BodyParameters") body = model.create_box(40, 20, 10, doc) sketch = next(member for member in body.Group if member.isDerivedFrom("Sketcher::SketchObject")) assert set(_rows(body)) == set(_rows(sketch)), "Existing generated-body contract changed" assert all(not row["read_only"] for row in _rows(body).values()) model.set_dimension(body, "QIHeight", 12) _near(body.Shape.Volume, 40 * 20 * 12) source = body.QIParameters source.setExpression("QIWidth", "QILength / 2") doc.recompute() assert _rows(body)["QIWidth"]["read_only"] formulas = (list(source.ExpressionEngine), list(body.ExpressionEngine)) model.set_dimensions(body, {"QIHeight": 15, "QIWidth": 20}) assert (list(source.ExpressionEngine), list(body.ExpressionEngine)) == formulas _near(body.Shape.Volume, 40 * 20 * 15) before = (source.QIWidth.Value, source.QIHeight.Value, body.Shape.Volume, doc.UndoCount) _reject(lambda: model.set_dimensions(body, {"QIWidth": 25, "QIHeight": 18})) assert (source.QIWidth.Value, source.QIHeight.Value, body.Shape.Volume, doc.UndoCount) == before # The native property editor may override the Body's delegation itself. body.setExpression("QIHeight", source.Name + ".QIWidth / 2") doc.recompute() assert _rows(body)["QIHeight"]["read_only"] previous_source = source.QIHeight.Value _reject(lambda: model.set_dimension(body, "QIHeight", 18)) _near(source.QIHeight.Value, previous_source) return {"internal_delegation_remains_editable": True, "source_formula_read_only": True, "overridden_body_formula_read_only": True} def invalid_inputs_and_units(): _, _, sketch, _ = _circle("InvalidParameters") _line(sketch) before = _state(sketch) for values in ({"ЦентрX": float("nan")}, {"ЦентрY": float("inf")}, {"ЦентрX": 1e20}, {"ЦентрX": True}, {"РадиусКруга": 0}, {"РадиусКруга": -1}, {"Наклон": "10 mm"}, {"ЦентрX": "30 deg"}, {"missing": 10}, {"ЦентрX": -18, "РадиусКруга": -1}): _reject(lambda values=values: model.set_dimensions(sketch, values)) assert _state(sketch) == before return {"invalid_numbers_units_unknown_parameters_rejected": True, "all_inputs_atomic": True} def persisted_parameters(): doc, _, sketch, ids = _circle("PersistedParameters") _, angle = _line(sketch) sketch.setExpression("Constraints[%d]" % ids["РадиусКруга"], "4 mm") sketch.setDriving(ids["ЦентрY"], False) model.set_dimensions(sketch, {"ЦентрX": -25, "Наклон": -30}) path = output / "typed-parameters.FCStd" doc.saveAs(str(path)) name = doc.Name App.closeDocument(name) restored_doc = App.openDocument(str(path)) restored = restored_doc.getObject("Sketch") rows = _rows(restored) _near(rows["ЦентрX"]["value"], -25) _near(rows["Наклон"]["value"], -30) assert rows["РадиусКруга"]["read_only"] and rows["ЦентрY"]["read_only"] formulas = list(restored.ExpressionEngine) model.set_dimension(restored, "Наклон", 60) _near(restored.getDatum(angle).Value, 60) assert list(restored.ExpressionEngine) == formulas return {"native_project": str(path), "typed_metadata_and_formulas_survive_reopen": True} def drawing_rejects_cached_failed_feature(): doc = model.new_part("DrawingSourceValidation") body = model.create_box(40, 20, 10, doc) feature = body.Tip profile = feature.Profile feature.Profile = None doc.recompute() assert "Invalid" in feature.State assert not feature.Shape.isNull(), "Regression requires FreeCAD's cached failed Shape" # An independent valid body must not hide the failed source body. model.create_box(10, 10, 10, doc) before = ({member.Name for member in doc.Objects}, doc.UndoCount) _reject(lambda: model.create_drawing(doc)) assert ({member.Name for member in doc.Objects}, doc.UndoCount) == before feature.Profile = profile doc.recompute() page = model.create_drawing(doc) assert len(page.Views) == 3 model.prepare_drawing(page) if App.GuiUp: import FreeCADGui as Gui gui_doc = Gui.getDocument(doc.Name) if gui_doc.getInEdit(): gui_doc.resetEdit() Gui.updateGui() return {"cached_invalid_source_rejected_before_document_mutation": True, "recovered_source_creates_three_native_views": True} def gui_editor(): import FreeCADGui as Gui from PySide import QtCore try: from PySide6 import QtTest except ImportError: from PySide2 import QtTest try: from PySide import QtWidgets except ImportError: from PySide import QtGui as QtWidgets import qi_ui doc, _, sketch, ids = _circle("TypedParameterDialog") _, angle = _line(sketch) sketch.setExpression("Constraints[%d]" % ids["ЦентрX"], "-12 mm") sketch.setDriving(ids["РадиусКруга"], False) doc.recompute() Gui.Selection.clearSelection() Gui.Selection.addSelection(sketch) qi_ui.show_dimensions() dialog = qi_ui._dimensions try: boxes = {name: dialog.findChild(QtWidgets.QDoubleSpinBox, "QI_Parameter_" + name) for name in _rows(sketch)} assert all(boxes.values()) assert boxes["ЦентрX"].isReadOnly() and boxes["РадиусКруга"].isReadOnly() assert "выражением" in boxes["ЦентрX"].toolTip() assert "Справочный" in boxes["РадиусКруга"].toolTip() assert boxes["Наклон"].suffix().strip() == "°" _near(boxes["Наклон"].value(), 45) apply = dialog.findChild(QtWidgets.QDialogButtonBox).button(QtWidgets.QDialogButtonBox.Apply) undo = (doc.UndoCount, doc.RedoCount) length_before = sketch.getDatum(next(row["index"] for row in _rows(sketch).values() if row["name"] == "ДлинаЛинии")).Value # The four-decimal display must not replace an untouched precise datum. QtTest.QTest.mouseClick(apply, QtCore.Qt.LeftButton) assert (doc.UndoCount, doc.RedoCount) == undo _near(_rows(sketch)["ДлинаЛинии"]["value"], length_before) boxes["ЦентрY"].setValue(-15) boxes["Наклон"].setValue(60) formulas = list(sketch.ExpressionEngine) QtTest.QTest.mouseClick(apply, QtCore.Qt.LeftButton) _near(sketch.Geometry[0].Center.y, -15) _near(sketch.getDatum(angle).Value, 60) assert list(sketch.ExpressionEngine) == formulas assert not sketch.getDriving(ids["РадиусКруга"]) assert doc.UndoCount == undo[0] + 1 QtTest.QTest.mouseClick(apply, QtCore.Qt.LeftButton) assert doc.UndoCount == undo[0] + 1 QtWidgets.QApplication.processEvents() screenshot = output / "typed-parameter-dialog.png" assert dialog.grab().save(str(screenshot)) return {"screenshot": str(screenshot), "actual_apply_button": True, "untouched_precision_preserved": True, "one_native_undo_step": True} finally: dialog.close() Gui.Selection.clearSelection() try: for name, action in (("Signed coordinates, zero and undo redo", signed_coordinates_and_zero), ("Native degree and radian angle units", angle_units), ("Indexed and named formulas remain read only", expression_paths_and_noop), ("Native reference dimensions remain read only", reference_dimensions), ("Body VarSet delegation and user formulas", delegated_body_parameters), ("Invalid values and dimensional units rejected atomically", invalid_inputs_and_units), ("Typed parameter FCStd persistence", persisted_parameters), ("Drawing refuses cached invalid source geometry", drawing_rejects_cached_failed_feature)): check(name, action) if App.GuiUp: check("Qt parameter editor preserves readonly values, precision and undo", gui_editor) finally: for name in set(App.listDocuments()) - initial_docs: if App.GuiUp: import FreeCADGui as Gui gui_doc = Gui.getDocument(name) if gui_doc.getInEdit(): gui_doc.resetEdit() Gui.updateGui() App.Console.PrintLog("QInventor parameters test: close document " + name + "\n") App.closeDocument(name) if active_name and active_name in App.listDocuments(): App.setActiveDocument(active_name) report = {"passed": all(item["passed"] for item in checks), "count": len(checks), "gui": bool(App.GuiUp), "checks": checks} (output / "parameters-report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") return report