Files
Qinventor/tests/sketch_patterns_suite.py

599 lines
33 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)