541 lines
33 KiB
Python
541 lines
33 KiB
Python
"""Document-wide parameter table verified against native FreeCAD CAD kernels."""
|
||
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
|
||
import qi_parameters as parameters
|
||
|
||
|
||
def _near(actual, expected):
|
||
assert math.isclose(float(getattr(actual, "Value", actual)), expected, rel_tol=1e-9, abs_tol=1e-7), (actual, expected)
|
||
|
||
|
||
def _rows(doc):
|
||
return {row["key"]: row for row in parameters.list_document_parameters(doc)}
|
||
|
||
|
||
def _state(doc):
|
||
def native_content(obj):
|
||
# dumpContent is a ZIP whose DOS timestamps change without any CAD
|
||
# mutation. Compare every exact persisted member payload instead.
|
||
raw = bytes(obj.dumpContent())
|
||
if not zipfile.is_zipfile(io.BytesIO(raw)):
|
||
return raw
|
||
with zipfile.ZipFile(io.BytesIO(raw)) as archive:
|
||
return tuple((name, archive.read(name)) for name in sorted(archive.namelist()))
|
||
return ([(obj.Name, native_content(obj), list(obj.ExpressionEngine), list(obj.State)) for obj in doc.Objects],
|
||
doc.UndoCount, doc.RedoCount, App.getActiveTransaction(),
|
||
App.ActiveDocument.Name if App.ActiveDocument else None, set(App.listDocuments()))
|
||
|
||
|
||
def _reject(doc, action):
|
||
before = _state(doc)
|
||
try:
|
||
action()
|
||
except ValueError as exc:
|
||
assert any("а" <= c.lower() <= "я" for c in str(exc)), str(exc)
|
||
else:
|
||
raise AssertionError("Invalid parameters were accepted")
|
||
assert _state(doc) == before, "Rejected parameter batch modified document, Undo/Redo, active operation, or document set"
|
||
|
||
|
||
def _circle(name):
|
||
doc = model.new_part(name)
|
||
body = doc.addObject("PartDesign::Body", "Body")
|
||
sketch = body.newObject("Sketcher::SketchObject", "Sketch")
|
||
sketch.addGeometry(Part.Circle(App.Vector(), App.Vector(0, 0, 1), 5), False)
|
||
sketch.addConstraint(Sketcher.Constraint("Coincident", 0, 3, -1, 1))
|
||
index = sketch.addConstraint(Sketcher.Constraint("Radius", 0, 5))
|
||
sketch.renameConstraint(index, "Радиус")
|
||
doc.recompute()
|
||
assert sketch.solve() == 0
|
||
pad = body.newObject("PartDesign::Pad", "Pad")
|
||
pad.Profile = sketch
|
||
pad.Length = 10
|
||
doc.recompute()
|
||
assert pad.Shape.isValid() and len(pad.Shape.Solids) == 1
|
||
doc.clearUndos()
|
||
return doc, body, sketch, pad
|
||
|
||
|
||
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 ""
|
||
checks = []
|
||
report_path = output / "parameter-table-report.json"
|
||
|
||
def check(name, function):
|
||
report_path.write_text(json.dumps({"checks": checks, "running_case": name}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
App.Console.PrintLog("QInventor parameter table test: " + name + "\n")
|
||
try:
|
||
evidence = function()
|
||
checks.append({"name": name, "passed": True, "evidence": evidence})
|
||
except Exception:
|
||
checks.append({"name": name, "passed": False, "error": traceback.format_exc()})
|
||
|
||
def discovery():
|
||
doc = model.new_part("TableDiscovery")
|
||
body = model.create_box(30, 20, 10, doc=doc)
|
||
body.Label = "Одинаковая подпись"
|
||
source = body.QIParameters
|
||
source.Label = "Одинаковая подпись"
|
||
sketch = body.Tip.Profile[0]
|
||
extra = doc.addObject("App::FeaturePython", "Unrelated")
|
||
extra.addProperty("App::PropertyFloat", "Temperature")
|
||
snapshot = _state(doc)
|
||
rows = _rows(doc)
|
||
assert source.Name + ":QILength" in rows
|
||
assert body.Name + ":QILength" not in rows
|
||
assert sketch.Name + ":Constraints.Длина" in rows
|
||
assert body.Tip.Name + ":Length" in rows
|
||
assert extra.Name + ":Temperature" not in rows
|
||
assert len(rows) == len(set(rows)) and _state(doc) == snapshot
|
||
source.Label = "Другой заголовок"
|
||
assert set(_rows(doc)) == set(rows)
|
||
return {"native_controller_source": True, "no_discovery_mutation": True, "label_independent_ids": True, "rows": len(rows)}
|
||
|
||
def native_user_types():
|
||
doc = model.new_part("TableTypes")
|
||
parameters.apply_document_parameters(doc, additions=[
|
||
{"name": "Size", "kind": "length", "value": "2.5 cm", "comment": "Основной размер"},
|
||
{"name": "Rotation", "kind": "angle", "value": "1.5707963267948966 rad"},
|
||
{"name": "Scale", "kind": "scalar", "value": 2},
|
||
{"name": "Offset", "kind": "length", "value": "-2,5 мм"},
|
||
{"name": "Zero", "kind": "angle", "value": "0°"}])
|
||
source = doc.getObject("QIUserParameters")
|
||
assert source.TypeId == "App::VarSet" and source.getTypeIdOfProperty("Size") == "App::PropertyDistance"
|
||
assert source.getTypeIdOfProperty("Rotation") == "App::PropertyAngle"
|
||
assert source.getTypeIdOfProperty("Scale") == "App::PropertyFloat"
|
||
_near(source.Size, 25); _near(source.Rotation, 90); _near(source.Scale, 2); _near(source.Offset, -2.5)
|
||
assert _rows(doc)[source.Name + ":Size"]["comment"] == "Основной размер"
|
||
assert doc.UndoCount == 1
|
||
doc.undo(); doc.recompute()
|
||
assert doc.getObject("QIUserParameters") is None
|
||
doc.redo(); doc.recompute()
|
||
assert doc.getObject("QIUserParameters").Size.Value == 25
|
||
return {"native_types": ["Distance (signed length)", "Angle", "Float"], "unit_conversions": True, "russian_literals": True, "one_undo_creation": True}
|
||
|
||
def add_chain_and_bind_same_batch():
|
||
doc, body, sketch, pad = _circle("TableBindings")
|
||
parameters.apply_document_parameters(doc, edits=[
|
||
{"key": "Sketch:Constraints.Радиус", "expression": "QIUserParameters.Size / 2"},
|
||
{"key": "Pad:Length", "expression": "QIUserParameters.Height"}], additions=[
|
||
{"name": "Size", "kind": "length", "value": "12 mm"},
|
||
{"name": "Height", "kind": "length", "expression": "Size * Scale"},
|
||
{"name": "Scale", "kind": "scalar", "value": 2}])
|
||
_near(sketch.Geometry[0].Radius, 6); _near(pad.Length, 24)
|
||
_near(body.Shape.Volume, math.pi * 36 * 24)
|
||
assert doc.UndoCount == 1 and sketch.solve() == 0
|
||
doc.undo(); doc.recompute()
|
||
_near(sketch.Geometry[0].Radius, 5); _near(pad.Length, 10)
|
||
assert doc.getObject("QIUserParameters") is None and not sketch.ExpressionEngine and not pad.ExpressionEngine
|
||
doc.redo(); doc.recompute()
|
||
_near(body.Shape.Volume, math.pi * 36 * 24)
|
||
parameters.apply_document_parameters(doc, [{"key": "QIUserParameters:Size", "value": 16}])
|
||
_near(body.Shape.Volume, math.pi * 64 * 32)
|
||
return {"add_bind_chain_atomic": True, "native_expressions": True, "kernel_volume_mm3": body.Shape.Volume, "undo_redo": True}
|
||
|
||
def literal_noops_and_active_operations():
|
||
doc = model.new_part("TableNoop")
|
||
body = model.create_box(30, 20, 10, doc=doc)
|
||
row = _rows(doc)[body.QIParameters.Name + ":QILength"]
|
||
before = _state(doc)
|
||
parameters.apply_document_parameters(doc, [{"key": row["key"], "value": "3 cm", "expression": "", "comment": row["comment"]}])
|
||
assert _state(doc) == before
|
||
doc.openTransaction("Чужая операция")
|
||
try:
|
||
transaction = App.getActiveTransaction()
|
||
parameters.apply_document_parameters(doc, [{"key": row["key"], "value": 30}])
|
||
assert App.getActiveTransaction() == transaction
|
||
_reject(doc, lambda: parameters.apply_document_parameters(doc, [{"key": row["key"], "value": 31}]))
|
||
body.QIParameters.QILength = 32
|
||
_reject(doc, lambda: parameters.apply_document_parameters(doc, [{"key": row["key"], "value": 35}]))
|
||
finally:
|
||
doc.abortTransaction()
|
||
if App.getActiveTransaction() is not None:
|
||
App.closeActiveTransaction()
|
||
doc.recompute()
|
||
_near(body.Shape.Volume, 6000)
|
||
return {"numeric_noop_no_undo": True, "foreign_empty_and_pending_transaction_preserved": True}
|
||
|
||
def invalid_inputs_preserve_history():
|
||
doc = model.new_part("TableValidation")
|
||
body = model.create_box(30, 20, 10, doc=doc)
|
||
key = body.QIParameters.Name + ":QILength"
|
||
parameters.apply_document_parameters(doc, [{"key": key, "value": 35}])
|
||
doc.undo(); doc.recompute()
|
||
assert doc.RedoCount == 1
|
||
rejected = 0
|
||
for value in (0, -2, True, float("nan"), float("inf"), "2 deg", "abc", "100000001 mm"):
|
||
_reject(doc, lambda value=value: parameters.apply_document_parameters(doc, [{"key": key, "value": value}]))
|
||
rejected += 1
|
||
for name in ("Size X", "1Size", "QIPreserved", "mm", "deg", "pi", "true", "None"):
|
||
_reject(doc, lambda name=name: parameters.apply_document_parameters(doc, additions=[{"name": name, "kind": "length", "value": 10}]))
|
||
rejected += 1
|
||
_reject(doc, lambda: parameters.apply_document_parameters(doc, additions=[{"name": "Rotation", "kind": "angle", "value": 450}]))
|
||
_reject(doc, lambda: parameters.apply_document_parameters(doc, [{"key": key, "value": 31}], additions=[{"name": "Size", "value": 2}, {"name": "Size", "value": 3}]))
|
||
doc.redo(); doc.recompute(); _near(body.Shape.Volume, 7000)
|
||
return {"rejected_cases": rejected + 2, "existing_redo_branch_usable": True, "native_clamping_rejected": True}
|
||
|
||
def native_unit_expression_validation():
|
||
doc = model.new_part("TableExpressionUnits")
|
||
parameters.apply_document_parameters(doc, additions=[{"name": "Size", "kind": "length", "value": 10},
|
||
{"name": "Rotation", "kind": "angle", "value": 30}, {"name": "Scale", "kind": "scalar", "value": 2}])
|
||
invalid = [("Size", "Rotation"), ("Rotation", "Size"), ("Scale", "Size"), ("Size", "Size + Rotation")]
|
||
for name, expression in invalid:
|
||
_reject(doc, lambda name=name, expression=expression: parameters.apply_document_parameters(doc, [{"key": "QIUserParameters:" + name, "expression": expression}]))
|
||
parameters.apply_document_parameters(doc, [{"key": "QIUserParameters:Scale", "expression": "Size / 1 mm"},
|
||
{"key": "QIUserParameters:Rotation", "expression": "asin(0.5)"}])
|
||
_near(doc.getObject("QIUserParameters").Scale, 10); _near(doc.getObject("QIUserParameters").Rotation, 30)
|
||
return {"native_expression_quantity_checks": len(invalid), "native_functions_and_unit_conversion": True}
|
||
|
||
def native_bad_expressions_and_cycles():
|
||
doc = model.new_part("TableExpressionFailures")
|
||
parameters.apply_document_parameters(doc, additions=[{"name": "Size", "value": 10}, {"name": "Other", "value": 20}])
|
||
for expression in ("Missing.Parameter", "1 / 0", "Size +", "Size"):
|
||
_reject(doc, lambda expression=expression: parameters.apply_document_parameters(doc, [{"key": "QIUserParameters:Size", "expression": expression}]))
|
||
_reject(doc, lambda: parameters.apply_document_parameters(doc, [{"key": "QIUserParameters:Size", "expression": "Other"}, {"key": "QIUserParameters:Other", "expression": "Size"}]))
|
||
_reject(doc, lambda: parameters.apply_document_parameters(doc, additions=[{"name": "Broken", "expression": "Missing + 2 mm"}]))
|
||
assert doc.getObject("QIUserParameters").ExpressionEngine == []
|
||
return {"native_syntax_missing_divide_zero_self_and_cycle_rejected": True}
|
||
|
||
def safe_rename_and_comment_history():
|
||
doc, body, sketch, pad = _circle("TableRename")
|
||
parameters.apply_document_parameters(doc, edits=[{"key": "Pad:Length", "expression": "QIUserParameters.Size"}],
|
||
additions=[{"name": "Size", "value": 10, "comment": "Исходный"}, {"name": "DoubleSize", "expression": "Size * 2"}])
|
||
source = doc.getObject("QIUserParameters")
|
||
before_native_documentation = source.getDocumentationOfProperty("Size")
|
||
undo = doc.UndoCount
|
||
parameters.apply_document_parameters(doc, [{"key": "QIUserParameters:Size", "name": "Height", "comment": "Высота детали"},
|
||
{"key": "Pad:Length", "comment": "Связь с высотой"}])
|
||
assert source.getDocumentationOfProperty("Height") == before_native_documentation
|
||
assert "Height" in dict(pad.ExpressionEngine)["Length"]
|
||
assert dict(source.ExpressionEngine)["DoubleSize"] == "Height * 2"
|
||
assert _rows(doc)["QIUserParameters:Height"]["comment"] == "Высота детали"
|
||
assert _rows(doc)["Pad:Length"]["comment"] == "Связь с высотой"
|
||
assert doc.UndoCount == undo + 1
|
||
doc.undo(); doc.recompute()
|
||
assert "Size" in source.PropertiesList and "Height" not in source.PropertiesList
|
||
assert _rows(doc)["QIUserParameters:Size"]["comment"] == "Исходный"
|
||
assert _rows(doc)["Pad:Length"]["comment"] != "Связь с высотой"
|
||
doc.redo(); doc.recompute()
|
||
assert _rows(doc)["QIUserParameters:Height"]["comment"] == "Высота детали"
|
||
before_count = doc.UndoCount
|
||
parameters.apply_document_parameters(doc, [{"key": "QIUserParameters:Height", "comment": "Только комментарий"}])
|
||
assert doc.UndoCount == before_count + 1
|
||
doc.undo(); doc.recompute()
|
||
assert _rows(doc)["QIUserParameters:Height"]["comment"] == "Высота детали"
|
||
return {"native_dependency_rename": True, "comment_metadata_transactional": True, "comment_only_undo": True}
|
||
|
||
def safe_deletions_and_clear_formula():
|
||
doc, body, sketch, pad = _circle("TableDelete")
|
||
parameters.apply_document_parameters(doc, edits=[{"key": "Pad:Length", "expression": "QIUserParameters.Size"}],
|
||
additions=[{"name": "Size", "value": 15}, {"name": "DoubleSize", "expression": "Size * 2"}])
|
||
parameters.apply_document_parameters(doc, [{"key": "QIUserParameters:Size", "value": 16}])
|
||
doc.undo(); doc.recompute()
|
||
assert doc.RedoCount == 1
|
||
_reject(doc, lambda: parameters.apply_document_parameters(doc, deletions=["QIUserParameters:Size"]))
|
||
parameters.apply_document_parameters(doc, [{"key": "Pad:Length", "expression": ""},
|
||
{"key": "QIUserParameters:DoubleSize", "expression": ""}], deletions=["QIUserParameters:Size"])
|
||
assert "Size" not in doc.getObject("QIUserParameters").PropertiesList
|
||
_near(pad.Length, 15); _near(doc.getObject("QIUserParameters").DoubleSize, 30)
|
||
assert not pad.ExpressionEngine and not doc.getObject("QIUserParameters").ExpressionEngine
|
||
doc.undo(); doc.recompute()
|
||
assert "Size" in doc.getObject("QIUserParameters").PropertiesList and pad.ExpressionEngine
|
||
doc.redo(); doc.recompute()
|
||
assert "Size" not in doc.getObject("QIUserParameters").PropertiesList
|
||
_reject(doc, lambda: parameters.apply_document_parameters(doc, deletions=["Pad:Length"]))
|
||
return {"referenced_delete_rejected_with_redo": True, "native_cached_dangling_formula_detected": True,
|
||
"unbind_and_delete_same_batch": True, "clear_preserves_evaluated_value": True}
|
||
|
||
def reference_and_unnamed_constraints():
|
||
doc = model.new_part("TableConstraintKinds")
|
||
sketch = doc.addObject("Sketcher::SketchObject", "Sketch")
|
||
sketch.addGeometry(Part.LineSegment(App.Vector(), App.Vector(10, 10, 0)), False)
|
||
sketch.addConstraint(Sketcher.Constraint("Coincident", 0, 1, -1, 1))
|
||
length = sketch.addConstraint(Sketcher.Constraint("Distance", 0, math.sqrt(200)))
|
||
angle = sketch.addConstraint(Sketcher.Constraint("Angle", 0, math.pi / 4))
|
||
sketch.renameConstraint(angle, "Наклон")
|
||
ref = sketch.addConstraint(Sketcher.Constraint("DistanceX", 0, 1, 0, 2, 10))
|
||
sketch.setDriving(ref, False)
|
||
doc.recompute(); assert sketch.solve() == 0
|
||
rows = _rows(doc)
|
||
length_key = "Sketch:Constraints[%d]" % length
|
||
ref_key = "Sketch:Constraints[%d]" % ref
|
||
assert length_key in rows and rows[ref_key]["reference"] and not rows[ref_key]["editable"]
|
||
parameters.apply_document_parameters(doc, [{"key": length_key, "value": "20 mm"},
|
||
{"key": "Sketch:Constraints.Наклон", "expression": "30 deg"}])
|
||
_near(sketch.getDatum(angle), 30); _near(sketch.getDatum(length), 20)
|
||
_near(sketch.Geometry[0].EndPoint.x, 20 * math.cos(math.pi / 6))
|
||
_reject(doc, lambda: parameters.apply_document_parameters(doc, [{"key": ref_key, "expression": "10 mm"}]))
|
||
return {"unnamed_native_paths": True, "degrees_not_radians": True, "reference_readonly": True}
|
||
|
||
def solver_and_cached_shape_failure():
|
||
doc = model.new_part("TableSolverFailure")
|
||
sketch = doc.addObject("Sketcher::SketchObject", "Sketch")
|
||
sketch.addGeometry(Part.LineSegment(App.Vector(), App.Vector(8, 6, 0)), False)
|
||
sketch.addConstraint(Sketcher.Constraint("Coincident", 0, 1, -1, 1))
|
||
length = sketch.addConstraint(Sketcher.Constraint("Distance", 0, 10))
|
||
x = sketch.addConstraint(Sketcher.Constraint("DistanceX", 0, 1, 0, 2, 8))
|
||
doc.recompute(); assert sketch.solve() == 0
|
||
doc.clearUndos()
|
||
parameters.apply_document_parameters(doc, [{"key": "Sketch:Constraints[%d]" % x, "value": 7}])
|
||
doc.undo(); doc.recompute(); assert doc.RedoCount == 1
|
||
_reject(doc, lambda: parameters.apply_document_parameters(doc, [{"key": "Sketch:Constraints[%d]" % x, "value": 12}]))
|
||
doc2 = model.new_part("TableCachedShapeFailure")
|
||
body = model.create_box(30, 20, 10, doc=doc2)
|
||
pad = body.Tip
|
||
fillet = body.newObject("PartDesign::Fillet", "Fillet")
|
||
fillet.Base = (pad, ["Edge1"])
|
||
fillet.Radius = 1
|
||
doc2.recompute(); assert fillet.Shape.isValid() and fillet.Shape.Solids and "Invalid" not in fillet.State
|
||
parameters.apply_document_parameters(doc2, [{"key": "Fillet:Radius", "value": 2}])
|
||
doc2.undo(); doc2.recompute(); assert doc2.RedoCount == 1
|
||
_reject(doc2, lambda: parameters.apply_document_parameters(doc2, [{"key": "Fillet:Radius", "value": 100}]))
|
||
return {"native_unsolvable_constraints_rejected": True, "native_invalid_feature_with_cached_valid_shape_rejected": True,
|
||
"rejected_kernel_edits_preserve_redo": True}
|
||
|
||
def persistence():
|
||
doc, body, sketch, pad = _circle("TablePersistence")
|
||
parameters.apply_document_parameters(doc, [{"key": "Pad:Length", "expression": "QIUserParameters.Height"},
|
||
{"key": "Sketch:Constraints.Радиус", "expression": "QIUserParameters.Size / 2"}], additions=[
|
||
{"name": "Size", "value": 14, "comment": "Диаметр"}, {"name": "Height", "expression": "Size * 2"}])
|
||
parameters.apply_document_parameters(doc, [{"key": "QIUserParameters:Size", "comment": "Диаметр цилиндра"}])
|
||
file = output / "document-parameters.FCStd"
|
||
doc.saveAs(str(file))
|
||
expected = _rows(doc)
|
||
name = doc.Name
|
||
App.closeDocument(name)
|
||
doc = App.openDocument(str(file))
|
||
actual = _rows(doc)
|
||
assert set(actual) == set(expected)
|
||
for key in expected:
|
||
assert actual[key]["expression"] == expected[key]["expression"]
|
||
assert actual[key]["comment"] == expected[key]["comment"]
|
||
_near(actual[key]["value"], expected[key]["value"])
|
||
parameters.apply_document_parameters(doc, [{"key": "QIUserParameters:Size", "value": 16}])
|
||
_near(doc.getObject("Body").Shape.Volume, math.pi * 64 * 32)
|
||
return {"file": str(file), "native_saved_formulas_comments_values": True, "reopened_edit_rebuilds_solid": True}
|
||
|
||
def new_document_qualified_expressions():
|
||
doc = model.new_part("TableQualifiedExpressions")
|
||
body = model.create_box(30, 20, 10, doc=doc)
|
||
parameters.apply_document_parameters(doc, additions=[{"name": "Size", "value": 2}, {"name": "Other", "value": 5}])
|
||
parameters.apply_document_parameters(doc, [{"key": "QIUserParameters:Other", "value": 6}])
|
||
doc.undo(); doc.recompute()
|
||
doc.saveAs(str(output / "self-qualified-expression.FCStd"))
|
||
assert doc.RedoCount == 1
|
||
_reject(doc, lambda: parameters.apply_document_parameters(doc,
|
||
[{"key": "QIUserParameters:Size", "value": 0},
|
||
{"key": body.QIParameters.Name + ":QIHeight", "expression": doc.Name + "#QIUserParameters.Size"}]))
|
||
parameters.apply_document_parameters(doc,
|
||
[{"key": "QIUserParameters:Size", "value": 4},
|
||
{"key": body.QIParameters.Name + ":QIHeight", "expression": doc.Name + "#QIUserParameters.Size"}])
|
||
_near(body.QIParameters.QIHeight, 4); _near(body.Shape.Volume, 2400)
|
||
# Qualified quoted labels are native references, not string literals.
|
||
doc.getObject("QIUserParameters").Label = "Параметры проекта"
|
||
parameters.apply_document_parameters(doc,
|
||
[{"key": "QIUserParameters:Size", "value": 5},
|
||
{"key": body.QIParameters.Name + ":QIHeight", "expression": "<<" + doc.Label + ">>#<<Параметры проекта>>.Size"}])
|
||
_near(body.Shape.Volume, 3000)
|
||
# FreeCAD preserves Cyrillic internal document and object names. Those
|
||
# unquoted native identifiers must remap just like ASCII names.
|
||
unicode_doc = App.newDocument("Документ")
|
||
unicode_doc.UndoMode = 1
|
||
native = unicode_doc.addObject("App::VarSet", "Параметры")
|
||
native.addProperty("App::PropertyBool", "QIUserParameterSet")
|
||
native.QIUserParameterSet = True
|
||
native.addProperty("App::PropertyDistance", "Size")
|
||
native.Size = 2
|
||
box = unicode_doc.addObject("Part::Box", "Брусок")
|
||
box.Length, box.Width, box.Height = 10, 2, 3
|
||
unicode_doc.recompute()
|
||
unicode_doc.clearUndos()
|
||
parameters.apply_document_parameters(unicode_doc, [{"key": "Параметры:Size", "value": 4}])
|
||
unicode_doc.undo(); unicode_doc.recompute()
|
||
unicode_doc.saveAs(str(output / "unicode-qualified-expression.FCStd"))
|
||
assert unicode_doc.RedoCount == 1
|
||
_reject(unicode_doc, lambda: parameters.apply_document_parameters(unicode_doc,
|
||
[{"key": "Параметры:Size", "value": 0},
|
||
{"key": "Брусок:Height", "expression": "Документ#Параметры.Size"}]))
|
||
parameters.apply_document_parameters(unicode_doc,
|
||
[{"key": "Параметры:Size", "value": 5},
|
||
{"key": "Брусок:Height", "expression": "Документ#Параметры.Size"}])
|
||
_near(box.Shape.Volume, 100)
|
||
return {"new_qualified_expression_reads_proposed_values": True, "rejected_qualified_expression_preserves_redo": True,
|
||
"native_quoted_document_and_object_labels": True, "raw_cyrillic_document_and_object_identifiers": True}
|
||
|
||
def cross_document_native_dependencies():
|
||
source = model.new_part("TableExternalSource")
|
||
parameters.apply_document_parameters(source, additions=[{"name": "Size", "value": 10}])
|
||
consumer = model.new_part("TableExternalConsumer")
|
||
box = consumer.addObject("Part::Box", "Box")
|
||
box.Length, box.Width, box.Height = 10, 2, 3
|
||
# External document also owns a user table: native clone preflight must
|
||
# preserve both sets without treating the secondary table as editable.
|
||
parameters.apply_document_parameters(consumer, additions=[{"name": "Other", "value": 20}])
|
||
source.saveAs(str(output / "external-source.FCStd"))
|
||
consumer.saveAs(str(output / "external-consumer.FCStd"))
|
||
box.setExpression("Length", source.Name + "#QIUserParameters.Size")
|
||
consumer.recompute()
|
||
source.clearUndos(); consumer.clearUndos()
|
||
parameters.apply_document_parameters(source, [{"key": "QIUserParameters:Size", "value": 15}])
|
||
_near(box.Shape.Volume, 90)
|
||
assert source.UndoCount == 1 and consumer.UndoCount == 1
|
||
source.undo(); source.recompute(); consumer.recompute()
|
||
_near(box.Shape.Volume, 60)
|
||
source_state, consumer_state = _state(source), _state(consumer)
|
||
try:
|
||
parameters.apply_document_parameters(source, deletions=["QIUserParameters:Size"])
|
||
except ValueError:
|
||
pass
|
||
else:
|
||
raise AssertionError("External consumer reference deleted")
|
||
assert _state(source) == source_state and _state(consumer) == consumer_state
|
||
source.redo(); source.recompute(); consumer.recompute()
|
||
_near(box.Shape.Volume, 90)
|
||
parameters.apply_document_parameters(source, [{"key": "QIUserParameters:Size", "name": "Length"}])
|
||
assert "Length" in dict(box.ExpressionEngine)["Length"]
|
||
_near(box.Shape.Volume, 90)
|
||
return {"external_consumers_recomputed": True, "native_global_undo_redo": True,
|
||
"both_document_redo_preserved_after_rejected_delete": True, "external_rename_dependency_preserved": True}
|
||
|
||
def real_document_graph_and_saved_requirements():
|
||
initial_source = model.new_part("TableFirstExternalVariable")
|
||
initial_external = model.new_part("TableFirstExternalTable")
|
||
parameters.apply_document_parameters(initial_external, additions=[{"name": "Width", "value": 4}])
|
||
initial_source.saveAs(str(output / "first-external-source.FCStd"))
|
||
initial_external.saveAs(str(output / "first-external-target.FCStd"))
|
||
parameters.apply_document_parameters(initial_source, additions=[
|
||
{"name": "Size", "expression": initial_external.Name + "#QIUserParameters.Width"}])
|
||
assert initial_source.getObject("QIUserParameters") is not None
|
||
_near(initial_source.getObject("QIUserParameters").Size, 4)
|
||
source = model.new_part("TableSaveRulesSource")
|
||
parameters.apply_document_parameters(source, additions=[{"name": "Size", "value": 2}, {"name": "Other", "value": 5}])
|
||
external = model.new_part("TableSaveRulesExternal")
|
||
native = external.addObject("App::VarSet", "External")
|
||
native.addProperty("App::PropertyDistance", "Width")
|
||
native.Width = 4
|
||
external.saveAs(str(output / "save-rules-external.FCStd"))
|
||
parameters.apply_document_parameters(source, [{"key": "QIUserParameters:Other", "value": 6}])
|
||
source.undo(); source.recompute()
|
||
assert source.RedoCount == 1
|
||
_reject(source, lambda: parameters.apply_document_parameters(source,
|
||
[{"key": "QIUserParameters:Other", "value": 4},
|
||
{"key": "QIUserParameters:Size", "expression": external.Name + "#External.Width"}]))
|
||
source.saveAs(str(output / "save-rules-source.FCStd"))
|
||
parameters.apply_document_parameters(source,
|
||
[{"key": "QIUserParameters:Size", "expression": external.Name + "#External.Width"}])
|
||
_near(source.getObject("QIUserParameters").Size, 4)
|
||
parameters.apply_document_parameters(source, [{"key": "QIUserParameters:Size", "expression": "", "value": 2}])
|
||
external_native = external.addObject("App::VarSet", "Downstream")
|
||
external_native.addProperty("App::PropertyDistance", "Height")
|
||
external_native.setExpression("Height", source.Name + "#QIUserParameters.Other * 2")
|
||
external.recompute()
|
||
parameters.apply_document_parameters(source, [{"key": "QIUserParameters:Size", "value": 3}])
|
||
source.undo(); source.recompute(); external.recompute()
|
||
assert source.RedoCount == 1
|
||
_reject(source, lambda: parameters.apply_document_parameters(source,
|
||
[{"key": "QIUserParameters:Size", "expression": external.Name + "#External.Width"}]))
|
||
unsaved = model.new_part("TableUnsavedReference")
|
||
unsaved_native = unsaved.addObject("App::VarSet", "Unsaved")
|
||
unsaved_native.addProperty("App::PropertyDistance", "Width"); unsaved_native.Width = 7
|
||
_reject(source, lambda: parameters.apply_document_parameters(source,
|
||
[{"key": "QIUserParameters:Size", "expression": unsaved.Name + "#Unsaved.Width"}]))
|
||
return {"real_unsaved_owner_and_target_rejected_before_mutation": True,
|
||
"valid_saved_external_binding": True, "object_acyclic_document_cycle_rejected": True, "redo_preserved": True,
|
||
"first_external_bound_user_parameter_preserves_source_name": True}
|
||
|
||
def generated_plate_rules_and_grouped_drawing():
|
||
doc = model.new_part("TablePlateRules")
|
||
body = model.create_plate_with_holes(60, 30, 4, 6, 40, doc)
|
||
key = body.QIParameters.Name + ":QIHoleDiameter"
|
||
parameters.apply_document_parameters(doc, [{"key": key, "value": 8}])
|
||
doc.undo(); doc.recompute(); assert doc.RedoCount == 1
|
||
_reject(doc, lambda: parameters.apply_document_parameters(doc, [{"key": key, "value": 30}]))
|
||
_reject(doc, lambda: parameters.apply_document_parameters(doc,
|
||
[{"key": body.QIParameters.Name + ":QIHoleSpacing", "expression": "QIUserParameters.Spacing"}],
|
||
additions=[{"name": "Spacing", "value": 2}]))
|
||
page = model.create_drawing(doc)
|
||
group = doc.addObject("App::DocumentObjectGroup", "DrawingGroup")
|
||
group.Group = [body, page]
|
||
doc.recompute()
|
||
if App.GuiUp:
|
||
import FreeCADGui as Gui
|
||
model.prepare_drawing(page)
|
||
Gui.getDocument(doc.Name).resetEdit()
|
||
copied_types = []
|
||
native_batch = parameters._native_batch
|
||
|
||
def observe(current, plan):
|
||
if current.Name.startswith("QIParameterValidation"):
|
||
copied_types.extend(obj.TypeId for obj in current.Objects)
|
||
assert all(not obj.TypeId.startswith("TechDraw::") for obj in current.Objects)
|
||
assert len(current.getObject("DrawingGroup").Group) == 1
|
||
return native_batch(current, plan)
|
||
|
||
parameters._native_batch = observe
|
||
try:
|
||
parameters.apply_document_parameters(doc, [{"key": key, "value": 8}])
|
||
finally:
|
||
parameters._native_batch = native_batch
|
||
if App.GuiUp:
|
||
model.prepare_drawing(page)
|
||
Gui.getDocument(doc.Name).resetEdit()
|
||
assert copied_types and group.Group == [body, page]
|
||
assert body.Shape.isValid() and len(body.Shape.Solids) == 1
|
||
return {"generated_hole_fit_rules_preserved": True, "group_does_not_reimport_techdraw_in_clone": True,
|
||
"real_page_and_group_preserved": True, "clone_cad_objects": len(copied_types)}
|
||
|
||
cases = [("Discover native model sources and stable identities", discovery),
|
||
("Native user parameter types and Russian literals", native_user_types),
|
||
("Create chain and model bindings in one batch", add_chain_and_bind_same_batch),
|
||
("No-op and foreign CAD transactions", literal_noops_and_active_operations),
|
||
("Reject invalid inputs without losing existing redo", invalid_inputs_preserve_history),
|
||
("Validate native expression dimensions", native_unit_expression_validation),
|
||
("Native syntax missing references and cycles", native_bad_expressions_and_cycles),
|
||
("Native rename and transactional comments", safe_rename_and_comment_history),
|
||
("Referenced delete and clear formulas", safe_deletions_and_clear_formula),
|
||
("Unnamed angular and reference constraints", reference_and_unnamed_constraints),
|
||
("Native solver and cached shape errors", solver_and_cached_shape_failure),
|
||
("FCStd persistence and continued parametric rebuild", persistence),
|
||
("New native document-qualified expressions", new_document_qualified_expressions),
|
||
("Cross-document consumers and global Undo", cross_document_native_dependencies)]
|
||
cases.extend([("Real document cycles and FCStd requirements", real_document_graph_and_saved_requirements),
|
||
("Generated plate rules and grouped native drawing", generated_plate_rules_and_grouped_drawing)])
|
||
try:
|
||
for name, function in cases:
|
||
check(name, function)
|
||
finally:
|
||
if App.getActiveTransaction() is not None:
|
||
App.closeActiveTransaction()
|
||
for name in set(App.listDocuments()) - initial_docs:
|
||
App.closeDocument(name)
|
||
if active_name and App.getDocument(active_name):
|
||
App.setActiveDocument(active_name)
|
||
report = {"passed": all(check["passed"] for check in checks), "count": len(checks), "checks": checks,
|
||
"summary": {"total": len(checks), "passed": sum(check["passed"] for check in checks),
|
||
"failed": sum(not check["passed"] for check in checks)}, "gui_exercised": False}
|
||
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
return report
|
||
|
||
|
||
if __name__ == "__main__":
|
||
result = run(sys.argv[1] if len(sys.argv) > 1 else Path(__file__).resolve().parents[1] / "artifacts" / "parameter-table")
|
||
print(json.dumps(result["summary"]))
|
||
raise SystemExit(0 if result["passed"] else 1)
|