599 lines
33 KiB
Python
599 lines
33 KiB
Python
"""Real-kernel associative sketch pattern, persistence and transaction tests."""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import math
|
||
from pathlib import Path
|
||
import sys
|
||
import traceback
|
||
import xml.etree.ElementTree as ET
|
||
|
||
import FreeCAD as App
|
||
import Part
|
||
import Sketcher
|
||
|
||
_SOURCE = Path(__file__).resolve().parents[1] / "src" / "QInventor"
|
||
sys.path.insert(0, str(_SOURCE))
|
||
import qi_model as model
|
||
import qi_sketch_patterns as patterns
|
||
import qi_io
|
||
|
||
|
||
def _near(actual, expected, tolerance=1e-6):
|
||
assert abs(actual - expected) <= tolerance, (actual, expected)
|
||
|
||
|
||
def _point(actual, expected):
|
||
for value, target in zip(actual, expected):
|
||
_near(value, target)
|
||
|
||
|
||
def _solved(sketch, fully=True):
|
||
assert sketch.solve() == 0
|
||
assert not sketch.ConflictingConstraints and not sketch.RedundantConstraints
|
||
assert "Invalid" not in sketch.State
|
||
if fully:
|
||
assert sketch.FullyConstrained
|
||
|
||
|
||
def _dimension(sketch, name, value, unit="mm"):
|
||
index = next(index for index, constraint in enumerate(sketch.Constraints) if constraint.Name == name)
|
||
sketch.setDatum(index, App.Units.Quantity("%.17g %s" % (value, unit)))
|
||
sketch.Document.recompute()
|
||
|
||
|
||
def _state(sketch):
|
||
geometry = []
|
||
for curve in sketch.Geometry:
|
||
if curve.TypeId == "Part::GeomCircle":
|
||
geometry.append((curve.TypeId, tuple(round(value, 7) for value in curve.Center), round(curve.Radius, 7)))
|
||
elif curve.TypeId == "Part::GeomLineSegment":
|
||
geometry.append((curve.TypeId, tuple(round(value, 7) for value in curve.StartPoint), tuple(round(value, 7) for value in curve.EndPoint)))
|
||
else:
|
||
geometry.append((curve.TypeId, str(curve)))
|
||
return (geometry, [(constraint.Type, constraint.Name, constraint.First, constraint.FirstPos,
|
||
constraint.Second, constraint.SecondPos, constraint.Third, round(constraint.Value, 8),
|
||
constraint.Driving) for constraint in sketch.Constraints],
|
||
list(sketch.ExpressionEngine), getattr(sketch, "QISketchPatterns", None),
|
||
(sketch.Document.UndoCount, sketch.Document.RedoCount))
|
||
|
||
|
||
def _reject(sketch, function):
|
||
before = _state(sketch)
|
||
try:
|
||
function()
|
||
except patterns.PatternError as exc:
|
||
assert any("а" <= character.lower() <= "я" for character in str(exc)), str(exc)
|
||
else:
|
||
raise AssertionError("Unsupported pattern input was accepted")
|
||
assert _state(sketch) == before, "Rejected pattern changed geometry, constraints, metadata or history"
|
||
|
||
|
||
def run(output_dir):
|
||
out = Path(output_dir).absolute()
|
||
out.mkdir(parents=True, exist_ok=True)
|
||
initial_docs = set(App.listDocuments())
|
||
initial_active = App.ActiveDocument.Name if App.ActiveDocument else None
|
||
report = {"status": "running", "passed": 0, "failed": 0, "skipped": 0, "checks": [],
|
||
"artifacts": [], "source": patterns.__file__}
|
||
|
||
def check(name, function):
|
||
try:
|
||
detail = function()
|
||
report["checks"].append({"name": name, "passed": True, "status": "passed", "detail": detail})
|
||
report["passed"] += 1
|
||
except Exception as exc:
|
||
report["checks"].append({"name": name, "passed": False, "status": "failed", "error": str(exc),
|
||
"traceback": traceback.format_exc()})
|
||
report["failed"] += 1
|
||
|
||
def fixture(kind="circle", fixed=True, prefix="QIPatternFixture"):
|
||
doc = App.newDocument(prefix)
|
||
doc.UndoMode = 1
|
||
doc.Label = "Проверка связанного массива"
|
||
sketch = doc.addObject("Sketcher::SketchObject", "Sketch")
|
||
sketch.Label = "Связанный эскиз"
|
||
if kind == "circle":
|
||
seed = sketch.addGeometry(Part.Circle(App.Vector(5, 7), App.Vector(0, 0, 1), 2), False)
|
||
constraints = [Sketcher.Constraint("DistanceX", seed, 3, 5.), Sketcher.Constraint("DistanceY", seed, 3, 7.),
|
||
Sketcher.Constraint("Radius", seed, 2.)]
|
||
names = ["ИсходныйX", "ИсходныйY", "ИсходныйРадиус"]
|
||
else:
|
||
seed = sketch.addGeometry(Part.LineSegment(App.Vector(3, 4), App.Vector(6, 8)), False)
|
||
constraints = [Sketcher.Constraint("DistanceX", seed, 1, 3.), Sketcher.Constraint("DistanceY", seed, 1, 4.),
|
||
Sketcher.Constraint("DistanceX", seed, 2, 6.), Sketcher.Constraint("DistanceY", seed, 2, 8.)]
|
||
names = ["ИсходныйНачалоX", "ИсходныйНачалоY", "ИсходныйКонецX", "ИсходныйКонецY"]
|
||
if fixed:
|
||
for constraint, name in zip(constraints, names):
|
||
index = sketch.addConstraint(constraint)
|
||
sketch.renameConstraint(index, name)
|
||
doc.recompute()
|
||
doc.clearUndos()
|
||
return doc, sketch, seed
|
||
|
||
def expected_rectangular(sketch, record):
|
||
p = record["parameters"]
|
||
angle = math.radians(p["angle"])
|
||
created = iter(record["created_ids"])
|
||
for row in range(p["count_y"]):
|
||
for column in range(p["count_x"]):
|
||
if column == row == 0:
|
||
continue
|
||
offset = (column * p["spacing_x"] * math.cos(angle) - row * p["spacing_y"] * math.sin(angle),
|
||
column * p["spacing_x"] * math.sin(angle) + row * p["spacing_y"] * math.cos(angle), 0)
|
||
for seed in record["source_ids"]:
|
||
copy = next(created)
|
||
source_geometry = sketch.Geometry[seed]
|
||
copy_geometry = sketch.Geometry[copy]
|
||
assert sketch.getConstruction(copy) == sketch.getConstruction(seed)
|
||
for position in ((1, 2) if source_geometry.TypeId == "Part::GeomLineSegment" else (3,)):
|
||
point = sketch.getPoint(seed, position)
|
||
_point(sketch.getPoint(copy, position), tuple(value + delta for value, delta in zip(point, offset)))
|
||
if source_geometry.TypeId == "Part::GeomCircle":
|
||
_near(copy_geometry.Radius, source_geometry.Radius)
|
||
|
||
def expected_circular(sketch, record):
|
||
p = record["parameters"]
|
||
denominator = p["count"] if math.isclose(abs(p["angle"]), 360, abs_tol=1e-6) else p["count"] - 1
|
||
step = math.radians(p["angle"] / denominator)
|
||
created = iter(record["created_ids"])
|
||
for occurrence in range(1, p["count"]):
|
||
angle = occurrence * step
|
||
for seed in record["source_ids"]:
|
||
copy = next(created)
|
||
source_geometry = sketch.Geometry[seed]
|
||
for position in ((1, 2) if source_geometry.TypeId == "Part::GeomLineSegment" else (3,)):
|
||
point = sketch.getPoint(seed, position)
|
||
x, y = point.x - p["center"][0], point.y - p["center"][1]
|
||
_point(sketch.getPoint(copy, position), (p["center"][0] + x * math.cos(angle) - y * math.sin(angle),
|
||
p["center"][1] + x * math.sin(angle) + y * math.cos(angle), 0))
|
||
if source_geometry.TypeId == "Part::GeomCircle":
|
||
_near(sketch.Geometry[copy].Radius, source_geometry.Radius)
|
||
|
||
def mirror_circle():
|
||
doc, sketch, seed = fixture()
|
||
record = patterns.mirror(sketch, [seed], "vertical")
|
||
assert record["associative"] and len(record["created_ids"]) == 1
|
||
copy = record["created_ids"][0]
|
||
_point(sketch.Geometry[copy].Center, (-5, 7, 0))
|
||
_dimension(sketch, "ИсходныйX", 9)
|
||
_dimension(sketch, "ИсходныйРадиус", 4)
|
||
_point(sketch.Geometry[copy].Center, (-9, 7, 0))
|
||
_near(sketch.Geometry[copy].Radius, 4)
|
||
record = patterns.set_parameters(sketch, record["id"], axis="horizontal")
|
||
_point(sketch.Geometry[record["created_ids"][0]].Center, (9, -7, 0))
|
||
_solved(sketch)
|
||
return {"radius_and_position_associative": True, "axis_edit": True}
|
||
|
||
def mirror_line_and_selected_axis():
|
||
doc, sketch, seed = fixture("line")
|
||
axis = sketch.addGeometry(Part.LineSegment(App.Vector(1, 1), App.Vector(10, 10)), True)
|
||
constraints = [Sketcher.Constraint("DistanceX", axis, 1, 1.), Sketcher.Constraint("DistanceY", axis, 1, 1.),
|
||
Sketcher.Constraint("DistanceX", axis, 2, 10.), Sketcher.Constraint("DistanceY", axis, 2, 10.)]
|
||
for constraint, name in zip(constraints, ("ОсьНачалоX", "ОсьНачалоY", "ОсьКонецX", "ОсьКонецY")):
|
||
sketch.renameConstraint(sketch.addConstraint(constraint), name)
|
||
doc.recompute()
|
||
record = patterns.mirror(sketch, [seed], axis)
|
||
copy = record["created_ids"][0]
|
||
_point(sketch.getPoint(copy, 1), (4, 3, 0))
|
||
_point(sketch.getPoint(copy, 2), (8, 6, 0))
|
||
_dimension(sketch, "ИсходныйКонецX", 9)
|
||
_point(sketch.getPoint(copy, 2), (8, 9, 0))
|
||
_dimension(sketch, "ОсьКонецX", 12)
|
||
line = sketch.Geometry[axis]
|
||
direction = line.EndPoint - line.StartPoint
|
||
direction.normalize()
|
||
for position in (1, 2):
|
||
point = sketch.getPoint(seed, position)
|
||
relative = point - line.StartPoint
|
||
expected = line.StartPoint + direction * (2 * relative.dot(direction)) - relative
|
||
_point(sketch.getPoint(copy, position), expected)
|
||
_solved(sketch)
|
||
return {"both_endpoints_linked": True, "construction_axis_edit_associative": True}
|
||
|
||
def rectangular_circle():
|
||
doc, sketch, seed = fixture()
|
||
record = patterns.rectangular_pattern(sketch, [seed], 3, 2, 12, 15, 35)
|
||
assert len(record["created_ids"]) == 5 and len(record["helper_ids"]) == 2
|
||
assert all(sketch.getConstruction(index) for index in record["helper_ids"])
|
||
expected_rectangular(sketch, record)
|
||
_dimension(sketch, "ИсходныйX", 8)
|
||
_dimension(sketch, "ИсходныйРадиус", 3)
|
||
expected_rectangular(sketch, record)
|
||
record = patterns.set_parameters(sketch, record["id"], spacing_x=18, spacing_y=21, angle=-25)
|
||
expected_rectangular(sketch, record)
|
||
_solved(sketch)
|
||
assert sketch.ExpressionEngine, "Pattern dimensions have no native expressions"
|
||
return {"copies": 5, "source_dimensions_and_pattern_parameters_associative": True, "native_expressions": len(sketch.ExpressionEngine)}
|
||
|
||
def rectangular_signed_steps_and_lines():
|
||
doc, sketch, seed = fixture("line")
|
||
record = patterns.rectangular_pattern(sketch, [seed], 2, 3, -12, 15, 0)
|
||
expected_rectangular(sketch, record)
|
||
_dimension(sketch, "ИсходныйКонецY", 11)
|
||
record = patterns.set_parameters(sketch, record["id"], spacing_x=12, spacing_y=-15, angle=90)
|
||
expected_rectangular(sketch, record)
|
||
_dimension(sketch, record["dimension_names"]["ШагX"], -18)
|
||
record = patterns.list_patterns(sketch)[0]
|
||
_near(record["parameters"]["spacing_x"], -18)
|
||
expected_rectangular(sketch, record)
|
||
_solved(sketch)
|
||
return {"signed_steps": True, "native_dimension_positive_to_negative": True, "line_endpoints_linked": True}
|
||
|
||
def initially_unconstrained_seed():
|
||
doc, sketch, seed = fixture(fixed=False)
|
||
record = patterns.rectangular_pattern(sketch, [seed], 2, 2, 12, 15)
|
||
_solved(sketch, fully=False)
|
||
assert not sketch.FullyConstrained
|
||
sketch.moveGeometry(seed, 3, App.Vector(8, 9), False)
|
||
doc.recompute()
|
||
expected_rectangular(sketch, record)
|
||
sketch.addConstraint(Sketcher.Constraint("Radius", seed, 4.))
|
||
doc.recompute()
|
||
expected_rectangular(sketch, record)
|
||
assert all(abs(sketch.Geometry[index].Radius - 4) < 1e-6 for index in record["created_ids"])
|
||
return {"association_without_original_dimensions": True, "drag_and_later_radius_constraint_propagate": True}
|
||
|
||
def circular_circle_full_and_partial():
|
||
doc, sketch, seed = fixture()
|
||
record = patterns.circular_pattern(sketch, [seed], 4, (0, 0), 360)
|
||
assert len(record["created_ids"]) == 3
|
||
expected_circular(sketch, record)
|
||
centers = [tuple(round(value, 6) for value in sketch.Geometry[index].Center) for index in [seed] + record["created_ids"]]
|
||
assert len(set(centers)) == 4, "Full circle duplicated its source occurrence"
|
||
_dimension(sketch, "ИсходныйX", 8)
|
||
_dimension(sketch, "ИсходныйРадиус", 3)
|
||
expected_circular(sketch, record)
|
||
record = patterns.set_parameters(sketch, record["id"], center=(1, 2), angle=-180)
|
||
expected_circular(sketch, record)
|
||
_solved(sketch)
|
||
return {"full_circle_distinct_occurrences": 4, "negative_partial_angle": record["parameters"]["angle"], "source_changes_associative": True}
|
||
|
||
def circular_rotates_lines_and_native_angle():
|
||
doc, sketch, seed = fixture("line")
|
||
record = patterns.circular_pattern(sketch, [seed], 4, (0, 0), 360)
|
||
expected_circular(sketch, record)
|
||
_dimension(sketch, "ИсходныйКонецX", 9)
|
||
_dimension(sketch, record["dimension_names"]["УголШага"], 40, "deg")
|
||
record = patterns.list_patterns(sketch)[0]
|
||
_near(record["parameters"]["angle"], 120)
|
||
expected_circular(sketch, record)
|
||
_solved(sketch)
|
||
return {"line_orientation_rotates": True, "native_step_angle_drives_every_point": True}
|
||
|
||
def count_edits_and_undo_redo():
|
||
doc, sketch, seed = fixture()
|
||
base = (sketch.GeometryCount, sketch.ConstraintCount)
|
||
record = patterns.rectangular_pattern(sketch, [seed], 3, 2, -12, 15)
|
||
assert doc.UndoCount == 1
|
||
doc.undo()
|
||
doc.recompute()
|
||
assert (sketch.GeometryCount, sketch.ConstraintCount) == base and not patterns.list_patterns(sketch)
|
||
doc.redo()
|
||
doc.recompute()
|
||
record = patterns.list_patterns(sketch)[0]
|
||
expected_rectangular(sketch, record)
|
||
record = patterns.set_parameters(sketch, record["id"], count_x=2, count_y=4)
|
||
assert len(record["created_ids"]) == 7
|
||
expected_rectangular(sketch, record)
|
||
doc.undo()
|
||
doc.recompute()
|
||
assert len(patterns.list_patterns(sketch)[0]["created_ids"]) == 5
|
||
doc.redo()
|
||
doc.recompute()
|
||
assert len(patterns.list_patterns(sketch)[0]["created_ids"]) == 7
|
||
_solved(sketch)
|
||
circular_doc, circular_sketch, circular_seed = fixture()
|
||
circle = patterns.circular_pattern(circular_sketch, [circular_seed], 4)
|
||
circle = patterns.set_parameters(circular_sketch, circle["id"], count=6, center=(1, 2), angle=180)
|
||
assert len(circle["created_ids"]) == 5
|
||
expected_circular(circular_sketch, circle)
|
||
_solved(circular_sketch)
|
||
return {"create_is_one_undo_step": True, "counts_editable": [8, 6], "undo_redo_restores_geometry_and_registry": True}
|
||
|
||
def save_reopen_all_patterns():
|
||
artifacts = []
|
||
for kind in ("mirror", "rectangular", "circular"):
|
||
doc, sketch, seed = fixture()
|
||
record = (patterns.mirror(sketch, [seed]) if kind == "mirror" else patterns.rectangular_pattern(sketch, [seed], 3, 2, -12, 15, 30)
|
||
if kind == "rectangular" else patterns.circular_pattern(sketch, [seed], 5, (1, 2), 180))
|
||
geometry_ids = record["source_geometry_ids"] + record["created_geometry_ids"] + record["helper_geometry_ids"]
|
||
path = out / (kind + ".FCStd")
|
||
model.save_project(doc, path)
|
||
App.closeDocument(doc.Name)
|
||
restored = App.openDocument(str(path))
|
||
assert qi_io.restore_document_label(restored, path)
|
||
assert restored.Label == "Проверка связанного массива"
|
||
sketch = restored.getObject("Sketch")
|
||
record = patterns.list_patterns(sketch)[0]
|
||
assert record["source_geometry_ids"] + record["created_geometry_ids"] + record["helper_geometry_ids"] == geometry_ids
|
||
_dimension(sketch, "ИсходныйX", 9)
|
||
_dimension(sketch, "ИсходныйРадиус", 4)
|
||
if kind == "rectangular":
|
||
expected_rectangular(sketch, record)
|
||
record = patterns.set_parameters(sketch, record["id"], count_x=2)
|
||
expected_rectangular(sketch, record)
|
||
elif kind == "circular":
|
||
expected_circular(sketch, record)
|
||
record = patterns.set_parameters(sketch, record["id"], count=6)
|
||
expected_circular(sketch, record)
|
||
else:
|
||
_point(sketch.Geometry[record["created_ids"][0]].Center, (-9, 7, 0))
|
||
_solved(sketch)
|
||
assert all(abs(sketch.Geometry[index].Radius - 4) < 1e-6 for index in record["created_ids"])
|
||
artifacts.append(str(path))
|
||
App.closeDocument(restored.Name)
|
||
report["artifacts"].extend(artifacts)
|
||
return {"native_fcstd_reopened": artifacts, "geometry_ids_constraints_expressions_and_association_persist": True}
|
||
|
||
def selection_and_parameters_rejected_atomically():
|
||
doc, sketch, seed = fixture()
|
||
for function in (lambda: patterns.mirror(sketch, [], "vertical"), lambda: patterns.mirror(sketch, [-3]),
|
||
lambda: patterns.mirror(sketch, [999]), lambda: patterns.mirror(sketch, [seed], seed),
|
||
lambda: patterns.rectangular_pattern(sketch, [seed], 1, 1),
|
||
lambda: patterns.rectangular_pattern(sketch, [seed], 2, 1, 0, 1),
|
||
lambda: patterns.rectangular_pattern(sketch, [seed], 2, 1, math.nan, 1),
|
||
lambda: patterns.rectangular_pattern(sketch, [seed], 128, 128),
|
||
lambda: patterns.circular_pattern(sketch, [seed], 1),
|
||
lambda: patterns.circular_pattern(sketch, [seed], 4, (5, 7)),
|
||
lambda: patterns.circular_pattern(sketch, [seed], 4, (0, 0), math.inf),
|
||
lambda: patterns.circular_pattern(sketch, [seed], 4, (0, 0), 361)):
|
||
_reject(sketch, function)
|
||
for geometry in (Part.ArcOfCircle(Part.Circle(App.Vector(5, 7), App.Vector(0, 0, 1), 2), 0, math.pi / 2),
|
||
Part.Ellipse(App.Vector(5, 7), 4, 2)):
|
||
index = sketch.addGeometry(geometry, False)
|
||
doc.recompute()
|
||
_reject(sketch, lambda i=index: patterns.rectangular_pattern(sketch, [i]))
|
||
return {"rejected_inputs": 14, "russian_errors": True, "no_geometry_constraint_metadata_or_undo_changes": True}
|
||
|
||
def helper_visibility_native_persistence():
|
||
details = []
|
||
|
||
def flags(sketch, record):
|
||
master_names = set(record["dimension_names"].values())
|
||
for index in record["constraint_ids"]:
|
||
constraint = sketch.Constraints[index]
|
||
xml = ET.fromstring(constraint.Content)
|
||
assert xml.get("IsVisible") == ("1" if constraint.Name in master_names else "0")
|
||
assert xml.get("IsActive") == "1" and constraint.IsActive
|
||
assert xml.get("IsInVirtualSpace") == "0"
|
||
assert constraint.Driving
|
||
|
||
for kind in ("mirror", "rectangular", "circular"):
|
||
doc, sketch, seed = fixture()
|
||
sketch.setVisibility(0, False)
|
||
source_content = [constraint.Content for constraint in sketch.Constraints]
|
||
doc.clearUndos()
|
||
record = (patterns.mirror(sketch, [seed]) if kind == "mirror" else
|
||
patterns.rectangular_pattern(sketch, [seed], 3, 2, 12, 15) if kind == "rectangular" else
|
||
patterns.circular_pattern(sketch, [seed], 4))
|
||
flags(sketch, record)
|
||
assert [constraint.Content for constraint in sketch.Constraints[:3]] == source_content
|
||
_solved(sketch)
|
||
assert doc.UndoCount == 1
|
||
doc.undo()
|
||
doc.recompute()
|
||
assert sketch.GeometryCount == 1 and not patterns.list_patterns(sketch)
|
||
assert [constraint.Content for constraint in sketch.Constraints] == source_content
|
||
doc.redo()
|
||
doc.recompute()
|
||
record = patterns.list_patterns(sketch)[0]
|
||
flags(sketch, record)
|
||
_dimension(sketch, "ИсходныйРадиус", 3)
|
||
assert all(abs(sketch.Geometry[index].Radius - 3) < 1e-6 for index in record["created_ids"])
|
||
path = out / (kind + "-hidden-helper-constraints.FCStd")
|
||
model.save_project(doc, path)
|
||
App.closeDocument(doc.Name)
|
||
reopened = App.openDocument(str(path))
|
||
sketch = reopened.getObject("Sketch")
|
||
record = patterns.list_patterns(sketch)[0]
|
||
flags(sketch, record)
|
||
assert ET.fromstring(sketch.Constraints[0].Content).get("IsVisible") == "0"
|
||
assert all(ET.fromstring(sketch.Constraints[index].Content).get("IsVisible") == "1" for index in (1, 2))
|
||
_dimension(sketch, "ИсходныйX", 9)
|
||
_dimension(sketch, "ИсходныйРадиус", 4)
|
||
if kind == "rectangular":
|
||
record = patterns.set_parameters(sketch, record["id"], spacing_x=18, count_x=2)
|
||
expected_rectangular(sketch, record)
|
||
elif kind == "circular":
|
||
record = patterns.set_parameters(sketch, record["id"], center=(1, 2), count=5)
|
||
expected_circular(sketch, record)
|
||
else:
|
||
_point(sketch.Geometry[record["created_ids"][0]].Center, (-9, 7, 0))
|
||
flags(sketch, record)
|
||
_solved(sketch)
|
||
assert all(abs(sketch.Geometry[index].Radius - 4) < 1e-6 for index in record["created_ids"])
|
||
details.append({"kind": kind, "native_hidden_helpers": len(record["constraint_ids"]) - len(record["dimension_names"]),
|
||
"visible_master_dimensions": len(record["dimension_names"]),
|
||
"source_visibility_unchanged": True, "native_undo_redo_reopen_persist": True,
|
||
"hidden_constraints_still_active_and_associative": True})
|
||
report["artifacts"].append(str(path))
|
||
App.closeDocument(reopened.Name)
|
||
return {"patterns": details}
|
||
|
||
def count_dependency_rejected_atomically():
|
||
doc, sketch, seed = fixture()
|
||
record = patterns.rectangular_pattern(sketch, [seed], 2, 2)
|
||
copy = record["created_ids"][0]
|
||
reference = sketch.addConstraint(Sketcher.Constraint("Radius", copy, 2.))
|
||
sketch.setDriving(reference, False)
|
||
doc.recompute()
|
||
_solved(sketch)
|
||
_reject(sketch, lambda: patterns.set_parameters(sketch, record["id"], count_x=3))
|
||
sketch.delConstraint(reference)
|
||
point = sketch.addGeometry(Part.Point(App.Vector(80, 90)), True)
|
||
sketch.addConstraint([Sketcher.Constraint("DistanceX", point, 1, 80.), Sketcher.Constraint("DistanceY", point, 1, 90.)])
|
||
doc.recompute()
|
||
_reject(sketch, lambda: patterns.set_parameters(sketch, record["id"], count_y=3))
|
||
record = patterns.set_parameters(sketch, record["id"], spacing_x=-20)
|
||
expected_rectangular(sketch, record)
|
||
return {"reference_constraint_dependency_rejected": True, "later_geometry_rejected_for_count_edit": True,
|
||
"spacing_still_editable": True}
|
||
|
||
def stable_ids_after_unrelated_geometry_delete():
|
||
doc = App.newDocument("QIPatternStableIds")
|
||
doc.UndoMode = 1
|
||
sketch = doc.addObject("Sketcher::SketchObject", "Sketch")
|
||
extra = sketch.addGeometry(Part.Point(App.Vector(90, 90)), True)
|
||
seed = sketch.addGeometry(Part.Circle(App.Vector(5, 7), App.Vector(0, 0, 1), 2), False)
|
||
for constraint, name in zip((Sketcher.Constraint("DistanceX", seed, 3, 5.), Sketcher.Constraint("DistanceY", seed, 3, 7.),
|
||
Sketcher.Constraint("Radius", seed, 2.)), ("ИсходныйX", "ИсходныйY", "ИсходныйРадиус")):
|
||
sketch.renameConstraint(sketch.addConstraint(constraint), name)
|
||
doc.recompute()
|
||
record = patterns.rectangular_pattern(sketch, [seed], 2, 2)
|
||
persistent_ids = record["source_geometry_ids"] + record["created_geometry_ids"]
|
||
sketch.delGeometry(extra)
|
||
doc.recompute()
|
||
record = patterns.list_patterns(sketch)[0]
|
||
assert record["source_ids"] == [0]
|
||
assert record["source_geometry_ids"] + record["created_geometry_ids"] == persistent_ids
|
||
_dimension(sketch, "ИсходныйРадиус", 4)
|
||
expected_rectangular(sketch, record)
|
||
_solved(sketch)
|
||
return {"native_persistent_geometry_ids_survive_index_renumbering": True}
|
||
|
||
def bound_master_dimensions_preserved():
|
||
doc, sketch, seed = fixture()
|
||
record = patterns.rectangular_pattern(sketch, [seed], 2, 2, 12, 15)
|
||
sizes = doc.addObject("App::VarSet", "PatternSizes")
|
||
sizes.addProperty("App::PropertyLength", "Spacing")
|
||
sizes.Spacing = 20
|
||
sketch.setExpression("Constraints." + record["dimension_names"]["ШагX"], "PatternSizes.Spacing")
|
||
doc.recompute()
|
||
_reject(sketch, lambda: patterns.set_parameters(sketch, record["id"], count_x=3))
|
||
_reject(sketch, lambda: patterns.set_parameters(sketch, record["id"], spacing_x=25))
|
||
record = patterns.set_parameters(sketch, record["id"], spacing_y=-17)
|
||
sizes.Spacing = 25
|
||
doc.recompute()
|
||
record = patterns.list_patterns(sketch)[0]
|
||
_near(record["parameters"]["spacing_x"], 25)
|
||
expected_rectangular(sketch, record)
|
||
assert any(str(expression) == "PatternSizes.Spacing" for path, expression in sketch.ExpressionEngine)
|
||
_solved(sketch)
|
||
return {"external_expression_not_silently_removed": True, "bound_dimension_edits_rejected_atomically": True,
|
||
"other_parameters_remain_editable": True, "external_dimension_update_still_associative": True}
|
||
|
||
def repeated_preview_cancel_and_commit():
|
||
doc, sketch, seed = fixture()
|
||
original = _state(sketch)
|
||
content = sketch.dumpContent()
|
||
class Cancelled(Exception):
|
||
pass
|
||
try:
|
||
with model._transaction(doc, "Отменённый предпросмотр"):
|
||
patterns.rectangular_pattern(sketch, [seed], 2, 2)
|
||
assert doc.HasPendingTransaction and doc.UndoCount == 1 and doc.UndoNames == ["Отменённый предпросмотр"], "Nested service committed the preview"
|
||
# The UI owns this dynamic property, absent from its original
|
||
# snapshot. Native restoreContent alone does not remove it.
|
||
sketch.removeProperty("QISketchPatterns")
|
||
sketch.restoreContent(content)
|
||
doc.recompute()
|
||
assert not patterns.list_patterns(sketch)
|
||
patterns.circular_pattern(sketch, [seed], 5)
|
||
assert len(patterns.list_patterns(sketch)) == 1
|
||
raise Cancelled()
|
||
except Cancelled:
|
||
pass
|
||
assert _state(sketch) == original
|
||
with model._transaction(doc, "Подтверждённый предпросмотр"):
|
||
patterns.rectangular_pattern(sketch, [seed], 2, 2)
|
||
sketch.removeProperty("QISketchPatterns")
|
||
sketch.restoreContent(content)
|
||
doc.recompute()
|
||
final = patterns.circular_pattern(sketch, [seed], 4)
|
||
assert len(patterns.list_patterns(sketch)) == 1 and doc.HasPendingTransaction
|
||
assert doc.UndoCount == 1 and doc.UndoNames == ["Подтверждённый предпросмотр"]
|
||
assert doc.UndoCount == 1 and not doc.HasPendingTransaction
|
||
expected_circular(sketch, final)
|
||
doc.undo()
|
||
doc.recompute()
|
||
assert not patterns.list_patterns(sketch) and sketch.GeometryCount == 1
|
||
return {"repeated_preview_registry_clean": True, "cancel_restores_all_native_data": True,
|
||
"commit_is_exactly_one_undo_step": True}
|
||
|
||
def native_solver_failure_rolls_back():
|
||
doc, sketch, seed = fixture()
|
||
before = _state(sketch)
|
||
original = patterns._verified
|
||
def conflicting(candidate):
|
||
candidate.addConstraint(Sketcher.Constraint("Radius", seed, 99.))
|
||
original(candidate)
|
||
patterns._verified = conflicting
|
||
try:
|
||
_reject(sketch, lambda: patterns.rectangular_pattern(sketch, [seed], 3, 2))
|
||
finally:
|
||
patterns._verified = original
|
||
assert _state(sketch) == before
|
||
_solved(sketch)
|
||
return {"real_conflicting_constraint_injected_after_geometry_creation": True, "transaction_restores_geometry_constraints_history": True}
|
||
|
||
def patterned_holes_rebuild_real_solid():
|
||
doc = model.new_part("Плита с массивом отверстий")
|
||
body = model.create_box(100, 60, 10, doc)
|
||
sketch = body.newObject("Sketcher::SketchObject", "PatternHoles")
|
||
sketch.Placement.Base.z = 10
|
||
seed = sketch.addGeometry(Part.Circle(App.Vector(20, 15), App.Vector(0, 0, 1), 3), False)
|
||
for constraint, name in zip((Sketcher.Constraint("DistanceX", seed, 3, 20.), Sketcher.Constraint("DistanceY", seed, 3, 15.),
|
||
Sketcher.Constraint("Radius", seed, 3.)), ("ИсходныйX", "ИсходныйY", "ИсходныйРадиус")):
|
||
sketch.renameConstraint(sketch.addConstraint(constraint), name)
|
||
doc.recompute()
|
||
record = patterns.rectangular_pattern(sketch, [seed], 3, 2, 20, 25)
|
||
model.pocket(sketch, 10)
|
||
assert body.Shape.isValid() and len(body.Shape.Solids) == 1
|
||
_near(body.Shape.Volume, 60000 - 6 * math.pi * 3 ** 2 * 10, 1e-4)
|
||
_dimension(sketch, "ИсходныйРадиус", 4)
|
||
assert body.Shape.isValid()
|
||
_near(body.Shape.Volume, 60000 - 6 * math.pi * 4 ** 2 * 10, 1e-4)
|
||
record = patterns.set_parameters(sketch, record["id"], count_x=2, spacing_x=15)
|
||
_near(body.Shape.Volume, 60000 - 4 * math.pi * 4 ** 2 * 10, 1e-4)
|
||
path = out / "patterned-holes.FCStd"
|
||
body_name = body.Name
|
||
model.save_project(doc, path)
|
||
App.closeDocument(doc.Name)
|
||
restored = App.openDocument(str(path))
|
||
shape = restored.getObject(body_name).Shape
|
||
assert shape.isValid() and len(shape.Solids) == 1
|
||
_near(shape.Volume, 60000 - 4 * math.pi * 4 ** 2 * 10, 1e-4)
|
||
report["artifacts"].append(str(path))
|
||
return {"genuine_partdesign_pocket_solid": True, "radius_and_count_regenerate_volume": True,
|
||
"reopened_volume_mm3": shape.Volume}
|
||
|
||
try:
|
||
for name, function in (
|
||
("mirror_circle_native_association", mirror_circle),
|
||
("mirror_line_selected_moving_axis", mirror_line_and_selected_axis),
|
||
("rectangular_circle_native_dimensions", rectangular_circle),
|
||
("rectangular_signed_steps_and_line_endpoints", rectangular_signed_steps_and_lines),
|
||
("unconstrained_seed_association", initially_unconstrained_seed),
|
||
("circular_full_partial_negative_and_source_edit", circular_circle_full_and_partial),
|
||
("circular_lines_and_native_step_edit", circular_rotates_lines_and_native_angle),
|
||
("counts_and_native_undo_redo", count_edits_and_undo_redo),
|
||
("native_fcstd_save_reopen_all_patterns", save_reopen_all_patterns),
|
||
("native_helper_visibility_persistence_association", helper_visibility_native_persistence),
|
||
("selection_parameters_unsupported_curves_atomic", selection_and_parameters_rejected_atomically),
|
||
("count_dependencies_atomic_rejection", count_dependency_rejected_atomically),
|
||
("persistent_geometry_ids_after_renumbering", stable_ids_after_unrelated_geometry_delete),
|
||
("native_expression_bound_master_dimensions", bound_master_dimensions_preserved),
|
||
("nested_preview_repeat_cancel_commit", repeated_preview_cancel_and_commit),
|
||
("real_native_solver_conflict_atomic_rollback", native_solver_failure_rolls_back),
|
||
("pattern_holes_real_partdesign_solid_reopen", patterned_holes_rebuild_real_solid)):
|
||
check(name, function)
|
||
finally:
|
||
for name in set(App.listDocuments()) - initial_docs:
|
||
App.closeDocument(name)
|
||
if initial_active in App.listDocuments():
|
||
App.setActiveDocument(initial_active)
|
||
report["status"] = "passed" if not report["failed"] else "failed"
|
||
report["success"] = report["status"] == "passed"
|
||
(out / "sketch-patterns-report.json").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" / "sketch-patterns")
|
||
print(json.dumps({key: result[key] for key in ("status", "passed", "failed", "skipped")}))
|
||
for item in result["checks"]:
|
||
if not item["passed"]:
|
||
print(item["name"] + ": " + item["error"])
|
||
raise SystemExit(0 if result["success"] else 1)
|