350 lines
17 KiB
Python
350 lines
17 KiB
Python
"""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
|