"""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)